diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d8b57a73..184e0cce 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,6 +2,9 @@ version: 2 updates: - package-ecosystem: github-actions directory: / + # Updates are tested in staging like every other change; main only takes + # pull requests from staging. + target-branch: staging schedule: interval: monthly commit-message: diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 7b379491..b32f1b7d 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,3 +1,5 @@ + + ## What this changes diff --git a/.github/rulesets/approval.json b/.github/rulesets/approval.json new file mode 100644 index 00000000..6a80923b --- /dev/null +++ b/.github/rulesets/approval.json @@ -0,0 +1,27 @@ +{ + "name": "Maintainer approval", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": ["refs/heads/main", "refs/heads/staging"], + "exclude": [] + } + }, + "bypass_actors": [ + { "actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "pull_request" } + ], + "rules": [ + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_review_thread_resolution": false, + "allowed_merge_methods": ["merge", "squash", "rebase"] + } + } + ] +} diff --git a/.github/rulesets/main.json b/.github/rulesets/main.json new file mode 100644 index 00000000..f08bece3 --- /dev/null +++ b/.github/rulesets/main.json @@ -0,0 +1,70 @@ +{ + "name": "Protect main", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": [ + "refs/heads/main" + ], + "exclude": [] + } + }, + "bypass_actors": [], + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": false, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_review_thread_resolution": true, + "allowed_merge_methods": [ + "merge" + ] + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": false, + "required_status_checks": [ + { + "context": "Only staging merges into main" + }, + { + "context": "Format (changed lines)" + }, + { + "context": "Linux Debug" + }, + { + "context": "Linux Release" + }, + { + "context": "Linux Clang Release" + }, + { + "context": "Windows Debug" + }, + { + "context": "Windows Release" + }, + { + "context": "Shaders compile and are committed" + }, + { + "context": "Downstream game still builds" + } + ] + } + } + ] +} diff --git a/.github/rulesets/staging.json b/.github/rulesets/staging.json new file mode 100644 index 00000000..2bb3d750 --- /dev/null +++ b/.github/rulesets/staging.json @@ -0,0 +1,69 @@ +{ + "name": "Protect staging", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": [ + "refs/heads/staging" + ], + "exclude": [] + } + }, + "bypass_actors": [], + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": false, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_review_thread_resolution": true, + "allowed_merge_methods": [ + "merge", + "squash", + "rebase" + ] + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": false, + "required_status_checks": [ + { + "context": "Format (changed lines)" + }, + { + "context": "Linux Debug" + }, + { + "context": "Linux Release" + }, + { + "context": "Linux Clang Release" + }, + { + "context": "Windows Debug" + }, + { + "context": "Windows Release" + }, + { + "context": "Shaders compile and are committed" + }, + { + "context": "Downstream game still builds" + } + ] + } + } + ] +} diff --git a/.github/workflows/branch-policy.yml b/.github/workflows/branch-policy.yml new file mode 100644 index 00000000..e2196fcc --- /dev/null +++ b/.github/workflows/branch-policy.yml @@ -0,0 +1,43 @@ +name: Branch policy + +# staging is the testing layer: every change lands there first, and main only +# takes pull requests from staging. This check fails any other pull request +# into main. It also runs on pull requests into staging, where it always +# passes, so retargeting a pull request from main to staging clears the +# failure it picked up there. +# +# pull_request_target runs this file as it exists on the base branch, so a +# pull request cannot edit the check into passing. Nothing from the pull +# request is checked out or run, and the job holds no token permissions. +on: + pull_request_target: + branches: [main, staging] + types: [opened, reopened, synchronize, edited] + +permissions: {} + +jobs: + # The job name is a required status check in .github/rulesets/main.json. + # Keep the verdict inside the step: a job skipped by an `if:` reports + # success, which would satisfy the required check. + staging-to-main: + name: Only staging merges into main + runs-on: ubuntu-latest + steps: + - name: Check where the pull request comes from + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + run: | + if [ "$BASE_REF" != "main" ]; then + echo "Targets $BASE_REF, which accepts pull requests from any branch." + exit 0 + fi + if [ "$HEAD_REPO" = "$GITHUB_REPOSITORY" ] && [ "$HEAD_REF" = "staging" ]; then + echo "Release pull request from staging into main." + exit 0 + fi + echo "This pull request comes from ${HEAD_REPO:-a deleted fork}:${HEAD_REF}." + echo "::error::main only accepts pull requests from staging. Edit this pull request and change its base branch to staging." + exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f289cd9..ed91db7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, staging] pull_request: - branches: [main] + branches: [main, staging] workflow_dispatch: # A newer push to the same branch makes the older run irrelevant. @@ -245,10 +245,12 @@ jobs: submodules: recursive path: SleakEngine + # Engine staging pairs with game staging, engine main with game main - name: Check out the game uses: actions/checkout@v7 with: repository: CanReader/SleakCraft + ref: ${{ github.base_ref }} path: SleakCraft - name: Install dependencies diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..c103fb02 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,57 @@ +# Contributing + +## Branches + +`staging` is the testing layer between a change and `main`. Everything lands in `staging` first, gets tested there, and reaches `main` only when `staging` is merged into it. Every game built on the engine follows `main`, so nothing reaches them untested. + +| Branch | Holds | Accepts | +|--------|-------|---------| +| `staging` | Changes being tested | Pull requests from any branch, once CI passes and a maintainer approves | +| `main` | Tested, released code | Pull requests from `staging` only, once CI passes and a maintainer approves | + +Open every pull request against `staging`. A pull request into `main` from any other branch fails the **Only staging merges into main** check and cannot be merged. To fix it, edit the pull request and change its base branch to `staging`. + +--- + +## Testing in staging + +Every pull request into `staging` runs the full CI: the format check, the Linux and Windows build matrix, the shader check, and the downstream game build. + +The downstream game build uses the SleakCraft branch of the same tier: pull requests into `staging` build SleakCraft `staging`, and pull requests into `main` build SleakCraft `main`. SleakCraft's own CI pairs the same way, so a change that spans both repos goes into both `staging` branches and is tested together. + +--- + +## Releasing staging to main + +1. Open a pull request from `staging` into `main`. +2. Wait for CI to pass. +3. Merge it with **Create a merge commit**. +4. When SleakCraft has matching changes, release its `staging` right after, so the game's `main` builds against the engine's `main` again. + +`main` only allows merge commits. Squash and rebase copy staging's commits into new ones, so the two branches would stop sharing history, and every later release would replay old commits and hit conflicts that aren't real. + +After a release, GitHub shows `staging` as behind `main`. The commits it is missing are the release merge commits, which change nothing on their own and need no action. + +--- + +## Branch protection + +The rules are the JSON files in `.github/rulesets/`, which GitHub imports as they are: **Settings → Rules → Rulesets → New ruleset → Import a ruleset**. + +| Rule | `main` | `staging` | +|------|--------|-----------| +| Changes arrive by pull request only | ✓ | ✓ | +| No force pushes, no deletion | ✓ | ✓ | +| CI must pass | ✓ | ✓ | +| Review threads resolved | ✓ | ✓ | +| A maintainer approves | ✓ | ✓ | +| Source must be `staging` | ✓ | | +| Merge method | Merge commit | Any | + +> **Setting it up?** Import `main.json` only after `.github/workflows/branch-policy.yml` is on `main`. The ruleset requires the check that workflow reports, so until the workflow exists nothing can merge into `main`. **Allow merge commits** must stay on under Settings → General → Pull Requests. + +The approval rule lives in its own ruleset, `approval.json`, because GitHub does not let authors approve their own pull requests. Repository admins can bypass that one ruleset, and only through a pull request, so a maintainer can merge their own work once CI passes. Everyone else needs a maintainer's approval, and a new push dismisses an earlier approval. + +Nobody can bypass the other two rulesets, admins included. To get out of a jam, set the ruleset's enforcement to **Disabled**, fix the problem, then set it back to **Active**. + +Required checks are matched by name, so renaming a job in `ci.yml` or `branch-policy.yml` means updating the rulesets to match.