Skip to content

PR tiers

Every pull request to romp-on/romp carries exactly one tier label, and a required status check named Tier policy holds the PR until its tier's gate is met. Two checks are required: Exactly one tier label and Tier policy, the check run the workflow posts through the API. The workflow's own job row, Tier policy: post verdict, is not a verdict: it is green whenever it evaluated and posted, whatever the verdict said, and red only when the evaluation itself failed (a fetch or post error). A PR that has seen several events shows several rows of Exactly one tier label and of Tier policy: post verdict, one per workflow run, each in its own check suite; every API-posted Tier policy run on a head lands in one check suite and the merge box shows only the newest, so that check shows one row. The hourly sweep's runs attach to main's sha, so on a PR's head the sweep leaves only the verdict, no job row. The gate was decided by the maintainers on 2026-09-07 and reset by the repository owner on 2026-09-08: it depends on the tier and on the author's role, and no tier has a time-based path. The rules are a pure function in scripts/ci/tier_policy.py, pinned by tests/test_tier_policy.py, and the workflow in .github/workflows/tier-policy.yml fetches PR data, applies the tier the body declares as a label when the PR carries none (below), re-runs the label counter on that head, and posts the verdict.

Declaring the tier. The PR template carries a line Tier: <tier>; replace the placeholder with one of docs, fix, feature or major-feature (so the line reads Tier: fix) and leave it on a line of its own. The Tier policy workflow reads that line and applies the matching label itself, so a contributor with read access, who cannot label a PR (labeling needs triage permission), sorts it this way; anyone who can label may still use gh pr create --label <tier> or the label picker. A label already on the PR wins: the workflow never changes an existing tier label, and a body that disagrees with it is only mentioned in the check's summary, so maintainers re-tier a PR by relabeling it. Removing a tier label is a ruling too: once someone other than the author has removed one, the body's line is not applied again (the PR waits for a maintainer's label), so re-tier by adding the label you want, or remove and add in one edit. The line is read case-insensitively: the first word after Tier: is the tier, and prose after it on the same line is allowed (Tier: fix, with the test below) unless it names another tier (Tier: fix or feature declares nothing); HTML comments and code blocks are excluded (the template's explanation never counts); two lines naming different tiers declare nothing, and the check says so. The label lands on the events the workflow already handles (opened, edited, synchronize, reopened) and on the hourly pass, and the workflow re-runs the "Exactly one tier label" check on that head, since a label added by a workflow's own token starts no run of its own; a counter that stayed red beside one correct label is re-run on the next event or hourly pass. A first-time contributor's workflow runs wait for a maintainer's approval in the Actions tab, and the check's summary says so. The body is data: the workflow still checks out only the base branch and executes nothing from the PR.

Roles are the author's collaborator permission on the repository, as the check reads it from the API: admin is the repository owner; write or maintain is a member; anyone else (read access, or no collaborator status at all, as for a fork PR from outside) is a contributor. The check gates members and contributors alike; the role that changes a gate is admin.

Tier What it is By the repository owner (an admin) By a member (write access) or a contributor
docs Documentation. To the check, the same tier as fix CI is green CI is green
fix A bug fix with a test that fails before it CI is green CI is green. The check requires no approval; whichever maintainer merges it is the whole requirement
feature A self-contained new capability inside romp's existing model CI is green: the owner's features merge straight away The owner approves the current head (the owner looks at the feature before it merges); no issue, no waiting period
major-feature New functionality that changes what romp does or its contracts The PR body links an issue (#N) that someone other than the author has commented on: the write-up and its discussion (the opener alone does not count) That discussed linked issue and the owner's approval on the current head

A standing change request holds every tier, for every author: when a maintainer (write, maintain or admin) other than the author has a standing change request, the PR waits until that reviewer lifts it. An approval by someone else does not lift it; only the objecting reviewer's own next review does.

Approval means an admin other than the PR author has a standing approval on the current head. A reviewer's standing is their latest approval, change request, or dismissal: comment-only reviews (GitHub files one for every inline comment) never change it. Dismissals are read conservatively in both directions: a dismissed approval never counts, whoever dismissed it (exactly as the PR page shows it), and a dismissed change request is cleared only when the reviewer dismissed it themselves, so a PR author with write access cannot dismiss a peer's change request to merge on green. A reviewer whose change request someone else dismissed lifts it by approving. A push after an approval needs a fresh approval. A write-holder's approval meets no gate: the only approval any gate asks for is the owner's.

No tier has a time-based path. Nothing in the check is timed: for its verdict it reads no check-run history, no issue timeline for dates and no commit date, so a PR's age, a reopen, a force-push or a sibling PR on the same head change no verdict. The workflow runs it lists to re-run the label counter never reach the verdict. A docs or fix PR merges when CI is green and no maintainer other than the author has a standing change request; so does the owner's feature; the rest wait for the owner's approval, the discussed issue, or both.

Renamed and copied files count under both their old and new path, so moving a file out of .github/ into docs/ is still a .github/ change. The API lists at most 3000 files per PR; when a PR has more, the unseen files are treated as guarded, so a member's or a contributor's PR needs the owner's approval whatever its tier.

Any PR that touches .github/ or scripts/ci/, the gate's own workflow and code, needs the owner's approval regardless of its tier when the author is not an admin. The owner's own PRs are exempt: the owner may change the gate, and the guard exists so that nobody else rewrites it through a PR the check cannot see. That rule is delivered by the very check a forged run competes with, so it is a human look, not a closure: see the residual below. The check runs from the base branch's copy of the workflow, so a PR cannot rewrite its own gate; but a PR that edits .github/ could add a job that posts a same-named success from its own copy on pull_request events, and no automation can stop that. The rule closes the gap with a human look.

Approvals reach the check through an hourly schedule (or a manual re-run of the workflow), because the review event runs in the PR's own context and is not trusted to drive the gate.

Stacked pull requests. Since 2026-09-10 the ruleset that turns every branch into a pull request ("branches arrive as PRs only") exempts one namespace, stack/**, so that GitHub's stacked pull requests are possible here: every layer of a stack must live in this repository, and a fork cannot host one. A member (write access) pushes a layer's branch as stack/<name> and opens the next layer against it; the bottom layer targets main. Stack branches are staging refs and nothing more: no ruleset targets them, and GitHub deletes them when their pull request merges. GitHub evaluates every layer of a stack against the stack base's required checks and reviews, so, in a stack based on main, a layer into stack/<name> merges only when it, and every layer below it, meets main's requirements. No check holds a pull request into stack/<name> unless it is a layer of such a stack. main keeps every rule above, so the layer that reaches main is gated exactly like any other pull request, and once a lower layer merges and the next is rebased onto main, that layer's head is new and needs a fresh approval (approval is on the current head, as above). A pull request whose head lives in this repository runs its own copy of every pull_request workflow with the repository's Actions token, and that copy carries whatever permissions it declares: the repository's read-only default bounds only a workflow with no permissions key (ci.yml), and a head in this repository can add or edit a workflow. The bounds on such a head are who can create one and what the token can reach: only whoever holds write access can push to stack/**; no ruleset has a bypass actor, so the token cannot push main; the repository holds no Actions secrets of its own; and the github-pages environment deploys from main only.

Residual, stated as open. A PR can add a pull_request-triggered workflow whose job is named Tier policy. GitHub Actions creates that job's check run on the PR head with no token at all (a fork PR's read-only token is no defence), under the same app and on the same sha as the base verdict, and the merge box follows the newest same-named run. Selecting the Actions app as the check's source cannot tell the two apart, and the in-check approval rule for .github/ and scripts/ci/ is delivered by the check the forged run competes with. What closes it: a CODEOWNERS entry for .github/** and scripts/ci/** with code-owner review required in the ruleset (it needs the maintainers' handles, so it is theirs to add), or posting the verdict from a dedicated GitHub App so app selection distinguishes it.

For the maintainers configuring the ruleset, in order: merge this change (the workflow's triggers fire only for a file on the default branch, so no run exists before that); dispatch the workflow once for an open PR and confirm the API-posted Tier policy run on its head; then require the check with the GitHub Actions app (integration id 15368) as its source, and add the CODEOWNERS rule above. Requiring the check before a run exists would deadlock main, since the ruleset has no bypass.