diff --git a/.github/workflows/moderate.yml b/.github/workflows/moderate.yml index 031f409..61a3ae4 100644 --- a/.github/workflows/moderate.yml +++ b/.github/workflows/moderate.yml @@ -11,6 +11,7 @@ concurrency: jobs: assess: + if: ${{ !(startsWith(github.event.pull_request.head.ref, 'catalog/pr-') && github.event.pull_request.head.repo.full_name == github.repository) }} runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -46,7 +47,7 @@ jobs: verdict: needs: assess - if: always() + if: ${{ always() && !(startsWith(github.event.pull_request.head.ref, 'catalog/pr-') && github.event.pull_request.head.repo.full_name == github.repository) }} runs-on: ubuntu-latest timeout-minutes: 5 permissions: diff --git a/.github/workflows/publish-catalog.yml b/.github/workflows/publish-catalog.yml index e726477..d09d008 100644 --- a/.github/workflows/publish-catalog.yml +++ b/.github/workflows/publish-catalog.yml @@ -17,7 +17,7 @@ on: permissions: contents: write - pull-requests: write + pull-requests: read statuses: read concurrency: @@ -67,9 +67,17 @@ jobs: echo "owner=$owner" } >> "$GITHUB_OUTPUT" + - uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 + id: moderator + with: + app-id: ${{ secrets.RUMBLE_MODERATOR_APP_ID }} + private-key: ${{ secrets.RUMBLE_MODERATOR_PRIVATE_KEY }} + owner: robocode-dev + repositories: rumble-bots - uses: actions/checkout@v4 with: ref: main + persist-credentials: false - uses: actions/setup-python@v5 with: python-version: '3.12' @@ -79,9 +87,18 @@ jobs: env: BOT_OWNER: ${{ steps.submission.outputs.owner }} run: GITHUB_SHA="$(git rev-parse HEAD)" python scripts/validate_bot.py --root . --owner "$BOT_OWNER" --generate + - uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 + id: publisher + with: + app-id: ${{ secrets.RUMBLE_PUBLISHER_APP_ID }} + private-key: ${{ secrets.RUMBLE_PUBLISHER_PRIVATE_KEY }} + owner: robocode-dev + repositories: rumble-bots - name: Open generated catalog pull request env: - GH_TOKEN: ${{ github.token }} + MODERATOR_TOKEN: ${{ steps.moderator.outputs.token }} + PUBLISHER_TOKEN: ${{ steps.publisher.outputs.token }} + DISPATCH_TOKEN: ${{ github.token }} SOURCE_PR: ${{ steps.submission.outputs.pull_request_number }} run: | set -euo pipefail @@ -91,13 +108,14 @@ jobs: if ! git diff --cached --quiet; then git commit -m 'chore: regenerate bot catalog' branch="catalog/pr-${SOURCE_PR}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" - git push origin "HEAD:refs/heads/${branch}" - pr_number="$(gh api "repos/${GITHUB_REPOSITORY}/pulls" \ + auth="$(printf 'x-access-token:%s' "$MODERATOR_TOKEN" | base64 -w0)" + git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth}" push origin "HEAD:refs/heads/${branch}" + pr_number="$(GH_TOKEN="$PUBLISHER_TOKEN" gh api "repos/${GITHUB_REPOSITORY}/pulls" \ -f title="chore: publish bot catalog after #${SOURCE_PR}" \ -f head="$branch" -f base=main \ -f body="Generated catalog for merged bot submission #${SOURCE_PR}. The moderator App verifies the exact generated files before merging." \ --jq .number)" - gh api "repos/${GITHUB_REPOSITORY}/dispatches" \ + GH_TOKEN="$DISPATCH_TOKEN" gh api "repos/${GITHUB_REPOSITORY}/dispatches" \ -f event_type=catalog_publication \ -F "client_payload[pull_request_number]=${pr_number}" \ -F "client_payload[source_pull_request_number]=${SOURCE_PR}" diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 6998391..ce67b63 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -32,6 +32,12 @@ Generate one private key under the App's **Credentials → Key pairs** page. Sto The organization installation created on 2026-10-20 has App ID `5402066`, slug `tank-royale-rumble-moderator`, and installation ID `173538338`. GitHub reports selected-repository access and the four write permissions above. Both named repository secrets were set on 2026-10-20; their values are not recorded here. The [documentation PR's moderation run](https://github.com/robocode-dev/rumble-bots/actions/runs/39499311781) successfully obtained an App token, set `bot-moderation` to pending, and commented as `tank-royale-rumble-moderator[bot]` because the PR changes protected documentation. +## Catalog publisher App registration + +Create a second organization-owned GitHub App named **Tank Royale Rumble Publisher** using the [pre-filled registration form](https://github.com/organizations/robocode-dev/settings/apps/new?name=Tank%20Royale%20Rumble%20Publisher&description=Opens%20generated%20Rumble%20catalog%20pull%20requests%20after%20a%20bot%20submission%20is%20merged.&url=https%3A%2F%2Fgithub.com%2Frobocode-dev%2Frumble-bots&public=false&request_oauth_on_install=false&webhook_active=false&contents=read&pull_requests=write). Check that its description is `Opens generated Rumble catalog pull requests after a bot submission is merged.` and its homepage is `https://github.com/robocode-dev/rumble-bots`. Leave callback and setup URLs empty, disable user authorization, device flow and webhooks, subscribe to no events, set installation to **Only on this account**, and install it with **Only select repositories → `robocode-dev/rumble-bots`**. Grant repository **Contents: Read** and **Pull requests: Read & write** only; leave all other permissions at their defaults. The expected slug is `tank-royale-rumble-publisher`, whose bot account opens catalog PRs. It cannot push catalog files, set moderation statuses or bypass branch rules, and the workflow never uses it to approve a review. The existing moderator App pushes the generated branch, verifies its exact content in a separate workflow, and approves and merges the Publisher App's PR. + +Generate a private key and save the Publisher App ID as `RUMBLE_PUBLISHER_APP_ID` and its full PEM contents as `RUMBLE_PUBLISHER_PRIVATE_KEY` in this repository's Actions secrets. Never commit the PEM file. Delete the downloaded local copy after securely storing the key, and rotate it in the App settings when needed. + ## GitHub Actions permissions The `pull_request_target` moderation workflow runs trusted code from `main` for every pull request. Its assessment job has a read-only token and no secrets. It fetches the exact PR commit as data, checks eligibility and source, then runs one real Battle Runner round in a pinned, non-root container with a read-only bot mount, limited resources, no capabilities and no GitHub credentials. Its separate verdict job never checks out or executes PR content. The repository's Actions event policy must explicitly allow this trusted workflow's `pull_request_target` event. Restrict this policy to this workflow; never run untrusted source in the verdict job. @@ -42,10 +48,10 @@ Install a dedicated GitHub App on **only** `robocode-dev/rumble-bots`, with Cont Protect `main` with a required `bot-moderation` status from the moderation App, a pull-request requirement, and code-owner review for the protected paths listed in `CODEOWNERS`. Bot directories have no human code owner, so a clean bot can merge after the App's approval. Flemming needs a pull-request-only bypass for policy changes he authors, because GitHub will not count an author's approval of their own pull request; use this exception only after human review of the change. Do not grant any bypass to the moderation App. Verify the App, status source, branch rule and publisher on a test PR before enabling auto-merge. The automatic security screen is a conservative filter, not proof that arbitrary bot source is harmless; clients still execute bots in their own sandbox. -**Deployment hold:** GitHub rejected `github-actions[bot]` as a ruleset bypass actor with HTTP 422 (`Actor GitHub Actions integration must be part of the ruleset source or owner organization`). No required-status branch ruleset is active yet. The generated catalog PR path must pass a live test before the rule is enabled; until then, do not claim that a failing bot is blocked from manual merge by GitHub. +**Deployment hold:** GitHub rejected `github-actions[bot]` as a ruleset bypass actor with HTTP 422 (`Actor GitHub Actions integration must be part of the ruleset source or owner organization`). The organization also forbids `GITHUB_TOKEN` from creating PRs, so a separate publisher App must be installed. No required-status branch ruleset is active yet. The generated catalog PR path must pass a live test before the rule is enabled; until then, do not claim that a failing bot is blocked from manual merge by GitHub. -After a bot pull request is merged by the App, the publisher runs on a push to `main` that changes `bots/**`. It checks that the merged PR's exact head had a successful `bot-moderation` status, then checks out trusted `main` and runs static catalog generation without executing a submitted launcher. It pushes only a `catalog/pr-*` branch and opens a pull request as `github-actions[bot]`. Because a pull request created with `GITHUB_TOKEN` does not start the moderator workflow, it dispatches the separate catalog verification workflow. That workflow reads the pull request as data without executing its contents, checks its source submission and exact generated files against trusted `main`, then uses the moderator App to approve and merge only the verified head. The publisher's `GITHUB_TOKEN` needs `contents: write`, `pull-requests: write`, and `statuses: read`; it uses no personal access token or repository secret. Generated catalog files have no CODEOWNERS entry because the App's verified approval satisfies the review requirement; policy and workflow files remain human-owned. +After a bot pull request is merged by the App, the publisher runs on a push to `main` that changes a bot directory. It checks that the merged PR's exact head had a successful `bot-moderation` status, then checks out trusted `main` and runs static catalog generation without executing a submitted launcher. The moderator App pushes only a `catalog/pr-*` branch. The separate Publisher App opens its pull request, and the workflow dispatches catalog verification. Verification reads the pull request as data without executing its contents, checks its source submission and exact generated files against trusted `main`, then uses the moderator App to approve and merge only the verified head. The workflow's `GITHUB_TOKEN` needs `contents: write` for dispatch and `pull-requests: read` plus `statuses: read` for source verification; it uses no personal access token. Generated catalog files have no CODEOWNERS entry because the moderator App's verified approval satisfies the review requirement; policy and workflow files remain human-owned. -For publication to work, GitHub Actions must be enabled and the repository or organization policy must permit these token scopes and the `repository_dispatch` verification event. The repository's **Settings → Actions → General → Workflow permissions** must allow read and write permissions for `GITHUB_TOKEN` (currently enabled) and **Allow GitHub Actions to create and approve pull requests** (currently disabled; enable before live publication testing). The latter repository-wide setting is required for `GITHUB_TOKEN` to create the generated PR even though the publisher never approves one; only the moderator App approves it. No bot account needs a direct-push bypass on `main`. Recheck these settings if organization policy or branch rules change. +For publication to work, GitHub Actions must be enabled and the repository or organization policy must permit these token scopes and the `repository_dispatch` verification event. The repository's **Settings → Actions → General → Workflow permissions** already allows read and write permissions for `GITHUB_TOKEN`. The organization keeps **Allow GitHub Actions to create and approve pull requests** disabled; the separate Publisher App opens the PR instead. Neither App needs a direct-push bypass on `main`. Recheck these settings if organization policy or branch rules change. On a `main` push, the publisher retries the commit-to-PR lookup for up to 55 seconds to allow GitHub to publish the association. To recover a merged submission whose publication still failed, a maintainer can run **Publish bot catalog** from `main` with its merged PR number. The dispatch verifies that it merged into `main` and that its head commit passed moderation before it regenerates the catalog using the PR author's account as owner. If the target branch advances during verification, the catalog PR remains blocked and the publisher must rerun against current `main`. If several bot submissions merge before publication finishes, verification holds the generated PR for Flemming rather than guessing which submitter owns each unpublished bot; reconcile ownership before publishing. diff --git a/moderation/verify_catalog.py b/moderation/verify_catalog.py index e8f5cb6..fa9cc4f 100644 --- a/moderation/verify_catalog.py +++ b/moderation/verify_catalog.py @@ -18,6 +18,7 @@ CATALOG_FILES = {"bots/index.json", "bots/owners.json"} APP_LOGIN = "tank-royale-rumble-moderator[bot]" +PUBLISHER_LOGIN = "tank-royale-rumble-publisher[bot]" class VerificationError(Exception): @@ -60,7 +61,7 @@ def verify(number: int, source_number: int) -> tuple[str, str]: base = pr["base"]["sha"] if (pr["state"] != "open" or pr["draft"] or pr["base"]["ref"] != "main" or pr["head"]["repo"]["full_name"] != os.environ["GITHUB_REPOSITORY"] - or pr["user"]["login"] != "github-actions[bot]" + or pr["user"]["login"] != PUBLISHER_LOGIN or not re.fullmatch(rf"catalog/pr-{source_number}-[0-9]+-[0-9]+", pr["head"]["ref"])): raise VerificationError("Not a publisher-created catalog PR on main") if git("rev-parse", "HEAD") != base: diff --git a/tests/test_moderation.py b/tests/test_moderation.py index 569d510..77c3d14 100644 --- a/tests/test_moderation.py +++ b/tests/test_moderation.py @@ -173,7 +173,7 @@ class CatalogVerificationTest(unittest.TestCase): def setUp(self): self.pr = {"state": "open", "draft": False, "base": {"ref": "main", "sha": "c" * 40}, "head": {"sha": SHA, "ref": "catalog/pr-7-123-1", "repo": {"full_name": "robocode-dev/rumble-bots"}}, - "user": {"login": "github-actions[bot]"}} + "user": {"login": verify_catalog.PUBLISHER_LOGIN}} def test_other_author_cannot_claim_generated_catalog(self): self.pr["user"]["login"] = "untrusted"