From 2e491f16dcc9fca754b1d7106377ee55db944428 Mon Sep 17 00:00:00 2001 From: Levi Waldron Date: Sun, 13 Sep 2026 22:01:50 -0400 Subject: [PATCH 1/8] Generate the index with a deploy key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Index generation has been failing since the require-pr-review ruleset was added: the generator runs, the push is rejected with GH013, because github-actions[bot] is not a bypass actor and cannot be made one — bypass actors of type Integration must be GitHub Apps installed on the organisation, and Actions is not one. A write-scoped deploy key is a valid bypass actor type and is scoped to this repository alone. actions/checkout takes it via `ssh-key`, loads it, and sets the remote to SSH, so the composite action's existing `git push` is unchanged. The key is an environment secret rather than a repository secret, in an environment whose deployment branch policy allows only `main`. A same-repo pull request does receive repository secrets, and this key bypasses the branch protection, so a contributor could otherwise read it out of a workflow added in a PR. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/generate-index.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/generate-index.yml b/.github/workflows/generate-index.yml index 2f2d24a..71d4bc9 100644 --- a/.github/workflows/generate-index.yml +++ b/.github/workflows/generate-index.yml @@ -15,8 +15,17 @@ permissions: jobs: generate-index: runs-on: ubuntu-latest + # The deploy key lives in this environment, whose deployment branch policy allows only `main`. + # A pull request from a branch in this repository would otherwise be able to read it — same-repo + # PRs do receive secrets — and the key bypasses branch protection. + environment: index-generation steps: + # Authenticates with a write-scoped deploy key rather than GITHUB_TOKEN: the require-pr-review + # ruleset blocks github-actions[bot], and Actions cannot be granted a bypass, because bypass + # actors of type Integration must be GitHub Apps installed on the organisation. - uses: actions/checkout@v4 + with: + ssh-key: ${{ secrets.INDEX_DEPLOY_KEY }} # @v0 is a moving tag, so this repository tracks the standard without a PR per release — # which is the point: an index generated by a stale generator is an index that disagrees with # the standard it claims to follow. Pin @v0.3.0 or a commit SHA instead if you need the action From 402d7cae77d9414039e5f909cd0c02e8c7cbf0ef Mon Sep 17 00:00:00 2001 From: Levi Waldron Date: Sun, 13 Sep 2026 22:20:26 -0400 Subject: [PATCH 2/8] Authenticate as a GitHub App, not a deploy key waldronlab-index-generation (app 4936017) holds `contents: write` and nothing else, is installed on this repository alone, and mints a token that expires after an hour. The deploy key it replaces was a static credential whose ruleset bypass covered every rule, including deletion and non-fast-forward. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/generate-index.yml | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/.github/workflows/generate-index.yml b/.github/workflows/generate-index.yml index 71d4bc9..37eacef 100644 --- a/.github/workflows/generate-index.yml +++ b/.github/workflows/generate-index.yml @@ -15,17 +15,24 @@ permissions: jobs: generate-index: runs-on: ubuntu-latest - # The deploy key lives in this environment, whose deployment branch policy allows only `main`. - # A pull request from a branch in this repository would otherwise be able to read it — same-repo - # PRs do receive secrets — and the key bypasses branch protection. + # The App credentials live in this environment, whose deployment branch policy allows only + # `main`. A pull request from a branch in this repository could otherwise read them — same-repo + # PRs do receive secrets — and they mint a token that bypasses branch protection. environment: index-generation steps: - # Authenticates with a write-scoped deploy key rather than GITHUB_TOKEN: the require-pr-review - # ruleset blocks github-actions[bot], and Actions cannot be granted a bypass, because bypass - # actors of type Integration must be GitHub Apps installed on the organisation. + # The require-pr-review ruleset blocks github-actions[bot], and GitHub Actions itself cannot be + # granted a bypass: bypass actors of type Integration must be GitHub Apps installed on the + # organisation, and Actions is not one. A purpose-built App is — this one holds `contents: + # write` and nothing else, and its token expires after an hour. + - uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ secrets.INDEX_APP_ID }} + private-key: ${{ secrets.INDEX_APP_PRIVATE_KEY }} + - uses: actions/checkout@v4 with: - ssh-key: ${{ secrets.INDEX_DEPLOY_KEY }} + token: ${{ steps.app-token.outputs.token }} # @v0 is a moving tag, so this repository tracks the standard without a PR per release — # which is the point: an index generated by a stale generator is an index that disagrees with # the standard it claims to follow. Pin @v0.3.0 or a commit SHA instead if you need the action From 527b87bb2a2fa5232ae58060a59336a7a01271ab Mon Sep 17 00:00:00 2001 From: Levi Waldron Date: Sun, 13 Sep 2026 22:31:51 -0400 Subject: [PATCH 3/8] Scope the token and attribute the commit to the App `permission-contents: write` is requested explicitly rather than inheriting whatever the installation holds, so granting the App another permission later cannot silently widen this token. GITHUB_TOKEN drops to `contents: read`: the push uses the installation token, so write here only handed every step a second write-capable credential. The commit is attributed to the App that pushes it rather than to github-actions[bot], which no longer has anything to do with it. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/generate-index.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/generate-index.yml b/.github/workflows/generate-index.yml index 37eacef..7c1b891 100644 --- a/.github/workflows/generate-index.yml +++ b/.github/workflows/generate-index.yml @@ -9,8 +9,10 @@ on: - 'protocols/**' - '.github/workflows/generate-index.yml' +# The push uses the App installation token, not GITHUB_TOKEN, so GITHUB_TOKEN needs no write +# access. Granting it would hand every step a second write-capable credential for nothing. permissions: - contents: write + contents: read jobs: generate-index: @@ -29,6 +31,9 @@ jobs: with: app-id: ${{ secrets.INDEX_APP_ID }} private-key: ${{ secrets.INDEX_APP_PRIVATE_KEY }} + # Request the scope explicitly rather than inheriting whatever the installation holds, so + # granting the App another permission later cannot silently widen this token. + permission-contents: write - uses: actions/checkout@v4 with: @@ -38,3 +43,8 @@ jobs: # the standard it claims to follow. Pin @v0.3.0 or a commit SHA instead if you need the action # to be immutable, and accept updating it by hand. - uses: waldronlab/agent-protocol-standard/actions/generate-index@v0 + with: + # Attribute the commit to the App that pushes it, rather than to github-actions[bot], + # which no longer has anything to do with this push. + committer-name: ${{ steps.app-token.outputs.app-slug }}[bot] + committer-email: ${{ secrets.INDEX_APP_ID }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com From 0a52fc144a08831aa5abcc27b3fa81c9d629babf Mon Sep 17 00:00:00 2001 From: Levi Waldron Date: Sun, 13 Sep 2026 22:42:59 -0400 Subject: [PATCH 4/8] Track main rather than @v0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @v0 resolved to a commit from before the Python rewrite — 22 behind main — so both workflows here were running the R tooling. Every pull request was being checked by the old R validator, and the Pydantic rules merged in the rewrite were enforcing nothing. CI was green the whole time. Tracking main also unblocks this PR: the committer-name and committer-email inputs it passes do not exist at @v0, and the R generator would have regenerated the index in R's YAML style, re-flattening the one-element database_urls and restoring `~` for `null` — partially reverting #40. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/generate-index.yml | 9 ++++----- .github/workflows/validate.yml | 4 +++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/generate-index.yml b/.github/workflows/generate-index.yml index 7c1b891..9f11e21 100644 --- a/.github/workflows/generate-index.yml +++ b/.github/workflows/generate-index.yml @@ -38,11 +38,10 @@ jobs: - uses: actions/checkout@v4 with: token: ${{ steps.app-token.outputs.token }} - # @v0 is a moving tag, so this repository tracks the standard without a PR per release — - # which is the point: an index generated by a stale generator is an index that disagrees with - # the standard it claims to follow. Pin @v0.3.0 or a commit SHA instead if you need the action - # to be immutable, and accept updating it by hand. - - uses: waldronlab/agent-protocol-standard/actions/generate-index@v0 + # @main, not a release tag. @v0 resolved to a commit from before the Python rewrite, so this + # workflow was silently running the old R generator while CI stayed green. A generator that + # has fallen behind produces an index that disagrees with the standard it claims to follow. + - uses: waldronlab/agent-protocol-standard/actions/generate-index@main with: # Attribute the commit to the App that pushes it, rather than to github-actions[bot], # which no longer has anything to do with this push. diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 34acf45..b6572e6 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -10,4 +10,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: waldronlab/agent-protocol-standard/actions/validate-protocols@v0 + # @main, not a release tag. @v0 predated the Python rewrite, so every pull request here was + # being checked by the old R validator — the Pydantic rules were enforcing nothing, silently. + - uses: waldronlab/agent-protocol-standard/actions/validate-protocols@main From 3c353528059812239e8a8dbe152ff2acda538f88 Mon Sep 17 00:00:00 2001 From: Levi Waldron Date: Sun, 13 Sep 2026 22:54:05 -0400 Subject: [PATCH 5/8] Address review: fix the committer identity, serialize runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The committer email used the App registration id from INDEX_APP_ID, but the address needs the bot's *user* id — verified against the API, the registration is 4936017 and the bot user is 328912240. With the wrong value the push still succeeds and GitHub silently declines to link the commit to the bot, which is exactly the sort of failure nobody notices. Resolved at run time instead of hardcoded, so the same workflow shape works in the template. Two merges in quick succession would have run concurrently, and the second push would have been rejected as non-fast-forward: its checkout predates the first run's index commit. Queued rather than cancelled, since a cancelled run leaves the index describing the previous commit. Separately, the ruleset is now split. The App's bypass was `always`, which exempts every rule in a ruleset — so it could have deleted or force-pushed `main`, not merely skipped review, and the least-privilege claim made for it was not true. `require-pr-review` now holds only pull_request and required_status_checks; a new `protect-main-history` holds deletion and non_fast_forward and is bypassed by nobody but the owner. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/generate-index.yml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/generate-index.yml b/.github/workflows/generate-index.yml index 9f11e21..c8fb846 100644 --- a/.github/workflows/generate-index.yml +++ b/.github/workflows/generate-index.yml @@ -14,6 +14,13 @@ on: permissions: contents: read +# Two merges in quick succession would otherwise run concurrently, and the second push would be +# rejected as non-fast-forward: its checkout predates the first run's index commit. Queue instead of +# cancelling — a cancelled run leaves the index describing the previous commit. +concurrency: + group: generate-index + cancel-in-progress: false + jobs: generate-index: runs-on: ubuntu-latest @@ -38,6 +45,15 @@ jobs: - uses: actions/checkout@v4 with: token: ${{ steps.app-token.outputs.token }} + # The email prefix below is the bot's user id, which is NOT the App registration id held in + # INDEX_APP_ID. Getting it wrong still pushes, but GitHub does not link the commit to the bot. + - name: Resolve the App's bot user id + id: bot + env: + GH_TOKEN: ${{ github.token }} + SLUG: ${{ steps.app-token.outputs.app-slug }} + run: echo "id=$(gh api "/users/${SLUG}[bot]" --jq .id)" >> "$GITHUB_OUTPUT" + # @main, not a release tag. @v0 resolved to a commit from before the Python rewrite, so this # workflow was silently running the old R generator while CI stayed green. A generator that # has fallen behind produces an index that disagrees with the standard it claims to follow. @@ -46,4 +62,4 @@ jobs: # Attribute the commit to the App that pushes it, rather than to github-actions[bot], # which no longer has anything to do with this push. committer-name: ${{ steps.app-token.outputs.app-slug }}[bot] - committer-email: ${{ secrets.INDEX_APP_ID }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com + committer-email: ${{ steps.bot.outputs.id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com From 33731fca3d65a2313512330db13c1cb3a0a373ca Mon Sep 17 00:00:00 2001 From: Levi Waldron Date: Sun, 13 Sep 2026 23:08:55 -0400 Subject: [PATCH 6/8] Address review: check out the tip, fail loudly, fix the local command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The concurrency group alone did not make the push safe. `actions/checkout` defaults to the event SHA, so a queued run still started from the commit that triggered it and pushed a non-fast-forward once `main` had moved. It checks out the branch tip now, which also means the index describes `main` as it is rather than as it was. `echo "id=$(gh api ...)"` exits 0 when the lookup fails, writing an empty id — so a failed lookup produced a malformed address and lost bot attribution silently, which is the same class of quiet failure the id itself was wrong for an hour ago. Assigned separately under `set -euo pipefail`, and empty fails the step. Added `workflow_dispatch`. Tracking the generator at `@main` means the next run uses it, but a generator change upstream raises no event here, so the index can sit stale until an unrelated local change. A manual trigger is the honest minimum; a schedule would be the fuller answer. CONTRIBUTING told contributors to run `Rscript ../agent-protocol-standard/scripts/validate-protocol.R`, which has not existed since the Python rewrite. Replaced, and the replacement was run against this repository's protocols to confirm it works. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/generate-index.yml | 16 +++++++++++++++- CONTRIBUTING.md | 2 +- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/generate-index.yml b/.github/workflows/generate-index.yml index c8fb846..4b51154 100644 --- a/.github/workflows/generate-index.yml +++ b/.github/workflows/generate-index.yml @@ -1,6 +1,10 @@ name: Generate Protocol Index on: + # Tracking the generator at @main means the next run uses it — but a generator change upstream + # raises no event here, so nothing regenerates until a local change does. Run this by hand after a + # generator change that alters the output. + workflow_dispatch: push: branches: [main] # The index is a function of the protocols *and* of the generator version pinned below, so a @@ -45,6 +49,10 @@ jobs: - uses: actions/checkout@v4 with: token: ${{ steps.app-token.outputs.token }} + # The branch tip, not the event SHA. Serialising runs is not enough on its own: a queued + # run would still check out the commit that triggered it and push a non-fast-forward once + # `main` had moved. It also means the index describes `main` as it is, not as it was. + ref: main # The email prefix below is the bot's user id, which is NOT the App registration id held in # INDEX_APP_ID. Getting it wrong still pushes, but GitHub does not link the commit to the bot. - name: Resolve the App's bot user id @@ -52,7 +60,13 @@ jobs: env: GH_TOKEN: ${{ github.token }} SLUG: ${{ steps.app-token.outputs.app-slug }} - run: echo "id=$(gh api "/users/${SLUG}[bot]" --jq .id)" >> "$GITHUB_OUTPUT" + run: | + # Assigned separately, not interpolated into echo: `echo "id=$(gh api ...)"` exits 0 even + # when the lookup fails, writing an empty id, and attribution is then silently wrong. + set -euo pipefail + id="$(gh api "/users/${SLUG}[bot]" --jq .id)" + [ -n "$id" ] || { echo "::error::could not resolve the bot user id for ${SLUG}[bot]"; exit 1; } + echo "id=$id" >> "$GITHUB_OUTPUT" # @main, not a release tag. @v0 resolved to a commit from before the Python rewrite, so this # workflow was silently running the old R generator while CI stayed green. A generator that diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 862792a..629f897 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,7 +23,7 @@ End the file with a `## History & Reviews` section as demonstrated in the templa ## Step 4: Validate and Submit Open a pull request. **You can rely on CI for validation.** The repository's automated testing infrastructure will check your syntax and tell you precisely what is wrong. Focus on getting the scientific correctness right. -*(To run the validator locally before pushing, clone `waldronlab/agent-protocol-standard` beside this repository and, from here, run `Rscript ../agent-protocol-standard/scripts/validate-protocol.R protocols`. It takes the protocols directory, not a single file, and validates every protocol in it.)* +*(To run the validator locally before pushing, clone `waldronlab/agent-protocol-standard` beside this repository, install its dependencies with `pip install 'pydantic>=2.0.0' pyyaml`, and from here run `python3 ../agent-protocol-standard/scripts/validate_protocol.py protocols`. It takes the protocols directory, not a single file, and validates every protocol in it.)* --- From a70b6416f1673f180e0cdf2ec040c0140047524c Mon Sep 17 00:00:00 2001 From: Levi Waldron Date: Sun, 13 Sep 2026 23:16:04 -0400 Subject: [PATCH 7/8] Pin the generator's ref to main as well as the checkout The checkout is forced to `main`, but the generator reads GITHUB_REF_NAME to build protocol_url values. On a workflow_dispatch from another branch or tag those disagree: the index would describe main's content under a different ref's URLs, and every URL in it would resolve to the wrong revision. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/generate-index.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/generate-index.yml b/.github/workflows/generate-index.yml index 4b51154..8854e0a 100644 --- a/.github/workflows/generate-index.yml +++ b/.github/workflows/generate-index.yml @@ -72,6 +72,11 @@ jobs: # workflow was silently running the old R generator while CI stayed green. A generator that # has fallen behind produces an index that disagrees with the standard it claims to follow. - uses: waldronlab/agent-protocol-standard/actions/generate-index@main + env: + # The checkout above is pinned to `main`, so the generator must build `protocol_url` + # values from `main` too. Without this, a workflow_dispatch from another branch or tag + # would index main's content under that ref's URLs. + GITHUB_REF_NAME: main with: # Attribute the commit to the App that pushes it, rather than to github-actions[bot], # which no longer has anything to do with this push. From c1491c1df1b8fbd772557a560b6e6fd34210399a Mon Sep 17 00:00:00 2001 From: Levi Waldron Date: Sun, 13 Sep 2026 23:23:58 -0400 Subject: [PATCH 8/8] Pass the ref as an input, not by overriding GITHUB_REF_NAME MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GITHUB_REF_NAME is runner-provided and a workflow cannot reliably override it through `env`, so the previous commit's attempt to force `main` for the generator would not have worked. The action takes a `ref` input instead, which the generator reads from PROTOCOL_INDEX_REF — a name the runner does not own. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/generate-index.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/generate-index.yml b/.github/workflows/generate-index.yml index 8854e0a..167566f 100644 --- a/.github/workflows/generate-index.yml +++ b/.github/workflows/generate-index.yml @@ -72,12 +72,12 @@ jobs: # workflow was silently running the old R generator while CI stayed green. A generator that # has fallen behind produces an index that disagrees with the standard it claims to follow. - uses: waldronlab/agent-protocol-standard/actions/generate-index@main - env: - # The checkout above is pinned to `main`, so the generator must build `protocol_url` - # values from `main` too. Without this, a workflow_dispatch from another branch or tag - # would index main's content under that ref's URLs. - GITHUB_REF_NAME: main with: + # The checkout above is pinned to `main`, so the generator must build `protocol_url` + # values from `main` too, or a workflow_dispatch from another branch would index main's + # content under that ref's URLs. Passed as an input because GITHUB_REF_NAME is + # runner-provided and a workflow cannot reliably override it. + ref: main # Attribute the commit to the App that pushes it, rather than to github-actions[bot], # which no longer has anything to do with this push. committer-name: ${{ steps.app-token.outputs.app-slug }}[bot]