Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/moderate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
28 changes: 23 additions & 5 deletions .github/workflows/publish-catalog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ on:

permissions:
contents: write
pull-requests: write
pull-requests: read
statuses: read

concurrency:
Expand Down Expand Up @@ -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'
Expand All @@ -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
Expand All @@ -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}"
Expand Down
12 changes: 9 additions & 3 deletions GOVERNANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
3 changes: 2 additions & 1 deletion moderation/verify_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_moderation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading