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
25 changes: 21 additions & 4 deletions .github/workflows/publish-catalog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ on:
branches: [main]
paths:
- 'bots/**'
- '!bots/index.json'
- '!bots/owners.json'
- '!bots/banned.json'
workflow_dispatch:
inputs:
pull_request_number:
Expand All @@ -14,7 +17,7 @@ on:

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

concurrency:
Expand Down Expand Up @@ -75,13 +78,27 @@ jobs:
- name: Regenerate catalog without running bots
env:
BOT_OWNER: ${{ steps.submission.outputs.owner }}
run: python scripts/validate_bot.py --root . --owner "$BOT_OWNER" --generate
- name: Commit and publish generated catalog
run: GITHUB_SHA="$(git rev-parse HEAD)" python scripts/validate_bot.py --root . --owner "$BOT_OWNER" --generate
- name: Open generated catalog pull request
env:
GH_TOKEN: ${{ github.token }}
SOURCE_PR: ${{ steps.submission.outputs.pull_request_number }}
run: |
set -euo pipefail
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add bots/index.json bots/owners.json
if ! git diff --cached --quiet; then
git commit -m 'chore: regenerate bot catalog'
git push origin HEAD:main
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" \
-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" \
-f event_type=catalog_publication \
-F "client_payload[pull_request_number]=${pr_number}" \
-F "client_payload[source_pull_request_number]=${SOURCE_PR}"
fi
1 change: 1 addition & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:
- tests/**
- .github/workflows/validate.yml
- .github/workflows/publish-catalog.yml
- .github/workflows/verify-catalog.yml
- .github/workflows/moderate.yml
- .github/workflows/manual-moderation.yml
- moderation/**
Expand Down
66 changes: 66 additions & 0 deletions .github/workflows/verify-catalog.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: Verify generated bot catalog

on:
repository_dispatch:
types: [catalog_publication]

jobs:
verify:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
statuses: read
outputs:
kind: ${{ steps.verify.outputs.kind }}
reason_b64: ${{ steps.verify.outputs.reason_b64 }}
sha: ${{ steps.verify.outputs.sha }}
base_sha: ${{ steps.verify.outputs.base_sha }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Regenerate and compare catalog on trusted main
id: verify
env:
GH_TOKEN: ${{ github.token }}
CATALOG_PR: ${{ github.event.client_payload.pull_request_number }}
SOURCE_PR: ${{ github.event.client_payload.source_pull_request_number }}
run: |
set -euo pipefail
[[ "$CATALOG_PR" =~ ^[0-9]+$ && "$SOURCE_PR" =~ ^[0-9]+$ ]]
python -m moderation.verify_catalog --pr "$CATALOG_PR" --source-pr "$SOURCE_PR" --output "$RUNNER_TEMP/catalog-verdict.json"
jq -r 'to_entries[] | "\(.key)=\(.value)"' "$RUNNER_TEMP/catalog-verdict.json" >> "$GITHUB_OUTPUT"

verdict:
needs: verify
if: ${{ always() && needs.verify.outputs.sha != '' }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349
id: app
with:
app-id: ${{ secrets.RUMBLE_MODERATOR_APP_ID }}
private-key: ${{ secrets.RUMBLE_MODERATOR_PRIVATE_KEY }}
owner: robocode-dev
repositories: rumble-bots
- name: Apply exact-commit catalog verdict
env:
GH_TOKEN: ${{ steps.app.outputs.token }}
CATALOG_PR: ${{ github.event.client_payload.pull_request_number }}
HEAD_SHA: ${{ needs.verify.outputs.sha }}
BASE_SHA: ${{ needs.verify.outputs.base_sha }}
VERDICT_KIND: ${{ needs.verify.outputs.kind }}
VERDICT_REASON_B64: ${{ needs.verify.outputs.reason_b64 }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
python moderation/moderate.py --pr "$CATALOG_PR" --sha "$HEAD_SHA" --base-sha "$BASE_SHA" --kind "$VERDICT_KIND" --reason-b64 "$VERDICT_REASON_B64" --run-url "$RUN_URL"
2 changes: 0 additions & 2 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,3 @@
/CONTRIBUTING.md @flemming-n-larsen
/README.md @flemming-n-larsen
/bots/banned.json @flemming-n-larsen
/bots/index.json @flemming-n-larsen
/bots/owners.json @flemming-n-larsen
8 changes: 4 additions & 4 deletions GOVERNANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,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:** The current catalog publisher pushes generated files directly to `main` using `GITHUB_TOKEN`. 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 publisher must move to a protected integration path before this rule can be 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`). 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. Its `GITHUB_TOKEN` needs `contents: write`, `pull-requests: read`, and `statuses: read`, as declared in `.github/workflows/publish-catalog.yml`; it uses no personal access token or repository secret.
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.

For publication to work, GitHub Actions must be enabled and the repository or organization policy must permit these token scopes. The repository's **Settings → Actions → General → Workflow permissions** should allow read and write permissions for `GITHUB_TOKEN` (currently enabled for this repository); the workflow itself still grants only the scopes listed above. If `main` is protected by branch rules, allow `github-actions[bot]` to push the generated catalog files, or publishing will fail. 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** 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.

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.
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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Read [CONTRIBUTING.md](CONTRIBUTING.md) for the full submission, ownership, lice

## Catalog

`bots/index.json` and `bots/owners.json` are generated by CI and must never be edited in a pull request. A catalog entry contains the bot identity, owner, source path, source-tree SHA-256, active lifecycle status, and `teamMembers`. Individual bots publish an empty member list; a TwinDuel team publishes exactly two active `<name> <version>` member identities, so a member release always forces the team entry to be resubmitted. A team directory contains nothing but its own JSON, its members may live under different platform directories, and its published platform is the members' shared platform or `Mixed`. Only `active` entries are eligible for matchmaking.
`bots/index.json` and `bots/owners.json` are generated by CI. Bot authors must not edit them; only the trusted catalog publisher opens generated-file pull requests, which the moderator App verifies before merge. A catalog entry contains the bot identity, owner, source path, source-tree SHA-256, active lifecycle status, and `teamMembers`. Individual bots publish an empty member list; a TwinDuel team publishes exactly two active `<name> <version>` member identities, so a member release always forces the team entry to be resubmitted. A team directory contains nothing but its own JSON, its members may live under different platform directories, and its published platform is the members' shared platform or `Mixed`. Only `active` entries are eligible for matchmaking.

This repository is designed to be forkable: its validator is standard-library Python. Pull-request assessment runs bot source with read-only permissions inside a restricted container; a separate trusted job makes the exact-commit review and merge decision. After merge, the `main` publisher verifies moderation and regenerates catalog data without launching bot code. See [moderator App setup](GOVERNANCE.md#moderator-app-registration) and [Actions permissions](GOVERNANCE.md#github-actions-permissions) for deployment and recovery.
Source-only catalog for ranked Tank Royale Rumble bots
6 changes: 3 additions & 3 deletions moderation/moderate.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ def automated(number: int, sha: str, kind: str, reason: str, run_url: str, base_
comment(number, f"@{MODERATOR} The target branch changed while `{sha[:12]}` was being tested. Please review the current base and retry or resolve with `/moderation approve <reason>`.")
return
reason = reason.strip()[:1200] or "Moderation did not produce a reason"
if pr["draft"] or kind not in {"clean", "reject", "escalate"}:
if pr["draft"] or kind not in {"clean", "catalog", "reject", "escalate"}:
kind = "escalate"
reason = "Draft PR or missing verdict: " + reason
if kind == "escalate":
Expand All @@ -83,12 +83,12 @@ def automated(number: int, sha: str, kind: str, reason: str, run_url: str, base_
# The trusted job reads the live head again immediately before every irreversible action.
api("POST", f"pulls/{number}/reviews", {
"event": "APPROVE", "commit_id": sha,
"body": f"Automated moderation passed for `{sha[:12]}`: {reason}",
"body": f"Automated {'catalog verification' if kind == 'catalog' else 'moderation'} passed for `{sha[:12]}`: {reason}",
})
if current_pr(number, sha, base_sha) is None:
print("PR changed after approval; merge skipped")
return
status(sha, "success", "Source and completed battle passed", run_url)
status(sha, "success", "Generated catalog verified" if kind == "catalog" else "Source and completed battle passed", run_url)
if current_pr(number, sha, base_sha) is None:
print("PR changed after status; merge skipped")
return
Expand Down
Loading
Loading