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: 3 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
<!-- Open this against staging. main only accepts pull requests from staging; see CONTRIBUTING.md. -->

## What this changes

<!-- One or two sentences. What behavior is different after this merges? -->
Expand Down
27 changes: 27 additions & 0 deletions .github/rulesets/approval.json
Original file line number Diff line number Diff line change
@@ -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"]
}
}
]
}
70 changes: 70 additions & 0 deletions .github/rulesets/main.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
}
]
}
69 changes: 69 additions & 0 deletions .github/rulesets/staging.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
}
]
}
43 changes: 43 additions & 0 deletions .github/workflows/branch-policy.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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.
Loading