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
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@
six ordered public pins with stable repository IDs in
`config/manual-settings.json`, verify them read-only through GraphQL, and use
the documented organization-owner UI step instead of an undocumented writer.
- `merge_queue` in `config/repositories.json` is the exhaustive merge-queue
inventory; `playtester` is the pilot. GitHub accepts the rule only in
repository rulesets, so `08 - Merge queue` stays repository-owned in both
scopes. Every queued repository is in `required_ci`, has every required check
reporting on `merge_group` before it is listed, never requires Dependency
review, and drops the up-to-date requirement from required CI only while it
is active and a fresh read verifies its installed queue. Install the queue before relaxing
required CI, restore the strict policy before retiring a queue, and retire
only an unmodified repository-owned rule that passes a fresh identity check
and final re-read.
- Keep required workflow job names synchronized with rulesets. Workflow or
permissions changes also require actionlint, least-privilege review, and
immutable action references according to policy.
Expand Down
13 changes: 13 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,19 @@ landed and passed on the protected branch. In particular, add Classic's
otherwise the required check would prevent the workflow pull request itself
from merging.

Add a repository to the `merge_queue` inventory only after every one of its
required checks reports on the `merge_group` event on its default branch. Keep
`08 - Merge queue` repository-owned, because GitHub rejects the rule in
organization rulesets. Keep its parameters within GitHub's API ranges, and use
the organization's only merge method. Queued repositories drop the up-to-date
requirement from required CI and never require Dependency review. Merge-queue
changes need schema negative tests and publisher tests for creation, a
converged rerun, drift convergence, required CI that stays strict without a
verified queue, retained foreign or modified rules, the identity and final-read
fences before retirement, and install-before-relax and restore-before-retire
ordering. They also need audit scenarios for a missing queue, parameter drift,
organization ownership, strict drift, and a retained queue.

Maintenance-branch retirement removes the active entry from
`config/repositories.json` and adds an immutable recovery record to
`config/retired-maintenance-branches.json`. Validate the complete repository,
Expand Down
63 changes: 63 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ read-only and are skipped on later runs.
and are not independent merge gates. The `classic` monorepo requires its
stable aggregate `Classic validation` and `CodeQL validation` jobs plus
`Conventional PR title`.
- Repositories in the `merge_queue` inventory merge through GitHub's merge
queue, and their required CI no longer requires an up-to-date branch because
the queue tests each merged result. `playtester` is the pilot. See
[Enabling a merge queue](#enabling-a-merge-queue).
- Declared maintenance branches reject deletion and non-fast-forward updates,
require linear history and pull requests, and require only checks already
emitted for that branch.
Expand Down Expand Up @@ -942,6 +946,65 @@ confirm that remaining pull requests and branches have been preserved at their
new location. For `atrinik/classic`, also complete any intentional tag rebuild
before applying immutable release-tag policy.

## Enabling a merge queue

`merge_queue` in `config/repositories.json` is the exhaustive list of
repositories that merge through GitHub's merge queue. GitHub accepts the
`merge_queue` rule only in repository rulesets, so both policy scopes install a
repository-owned `08 - Merge queue` ruleset on each listed repository's default
branch, as they do for `06 - CodeQL security`. The rule comes from
`config/rulesets/merge-queue.json`:

- squash merges, the organization's only merge method;
- `ALLGREEN` grouping, so every queued pull request's own merge result must
pass the required checks, not only the head of its group;
- a build concurrency of 5 and groups of 1 to 5 pull requests, with no wait:
a single queued pull request merges as soon as its checks pass, and pull
requests queued together share a group;
- a 60-minute timeout for required checks to report on a merge group.

Organization owners keep the usual pull-request-only bypass. During an
advisory merge window, the queue rule gets `always` bypass like that
repository's required CI.

A queued repository's `03 - Required CI` rule sets
`strict_required_status_checks_policy` to `false`. The queue already tests each
pull request merged onto the latest base, so the branch itself no longer has
to be current. When applying, the publisher re-reads the queue just before it
publishes required CI and relaxes the rule only if that read shows the exact
repository-owned queue. Otherwise it prints a `SKIP` and keeps required CI
strict. Every other repository keeps the strict policy. Validation requires
each listed repository to be in `required_ci`. It also keeps the template's
parameters within GitHub's API ranges and the allowed merge method, and allows
a wait only with a minimum group size above one.
GitHub Team offers merge queue only for public repositories, so the publisher
fails before any write if a listed active repository is private. Like the
security gates, it skips a listed repository that is not active; that
repository's required CI stays strict because no queue was installed.

Before adding a repository:

1. Merge a change in that repository so every required check also runs on the
`merge_group` event and reports under its existing name. A required check
that never reports on a merge group stalls the queue until the timeout.
Dependency review stays advisory, has no merge-group trigger, and must never
be a required gate on a queued repository; validation enforces that.
2. Add the repository to `merge_queue`, merge the reviewed change, and review
the `bin/publish` plan. It should create `08 - Merge queue` on that
repository. The publisher re-PUTs every managed ruleset on each run, and
that repository's `03 - Required CI` payload should be the only one whose
content changes. Then apply the plan with the usual explicit
authorization.

The publisher installs the queue before it relaxes required CI. When a
repository is removed from the inventory, it restores the strict policy first
and only then deletes the managed queue. It deletes only an unmodified,
repository-owned `08 - Merge queue` rule, and checks the repository's identity
and re-reads the rule immediately before the deletion. A modified, foreign or
concurrently edited rule with that name is retained with a `SKIP`, and `bin/audit` reports it until someone
reviews it. The audit also requires each listed repository's exact queue rule,
repository ownership, and non-strict required CI.

## Protecting and retiring a maintenance branch

Add a unique repository/branch entry to `maintenance_branches` in
Expand Down
47 changes: 42 additions & 5 deletions bin/audit
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,13 @@ has_required_ci() {
'.required_ci | has($repository)' "${repositories_config}" >/dev/null
}

has_merge_queue() {
local repository=$1
jq -e --arg repository "${repository}" \
'.merge_queue | index($repository) != null' "${repositories_config}" \
>/dev/null
}

has_release_tags() {
local repository=$1
jq -e --arg repository "${repository}" \
Expand Down Expand Up @@ -297,6 +304,10 @@ check_ruleset() {
record_finding "managed security ruleset must be repository-owned '${name}': ${organization}/${repository}"
return
fi
if [[ ${name} == '08 - Merge queue' && ${source} != Repository ]]; then
record_finding "managed merge-queue ruleset must be repository-owned: ${organization}/${repository}"
return
fi
case ${source} in
Organization) endpoint="orgs/${organization}/rulesets/${id}" ;;
Repository) endpoint="repos/${organization}/${repository}/rulesets/${id}" ;;
Expand All @@ -321,9 +332,12 @@ check_ruleset() {
'.rules[0].parameters.required_status_checks = $checks' <<<"${wanted}")
# Dependency review is governed by its separate rollout ruleset.
wanted=$(jq '.rules[0].parameters.required_status_checks |= map(select(.context != "Dependency review"))' <<<"${wanted}")
wanted=$(jq --argjson strict "${expected_strict}" \
'.rules[0].parameters.strict_required_status_checks_policy = $strict' <<<"${wanted}")
fi
if jq -e --arg repository "${repository}" '.repositories | index($repository) != null' \
"${advisory_windows}" >/dev/null && [[ ${name} == '03 - Required CI - '* ]]; then
"${advisory_windows}" >/dev/null &&
[[ ${name} == '03 - Required CI - '* || ${name} == '08 - Merge queue' ]]; then
wanted=$(jq '.bypass_actors[].bypass_mode = "always"' <<<"${wanted}")
fi
if [[ ${source} == Organization ]]; then
Expand Down Expand Up @@ -545,6 +559,12 @@ for repository in "${!expected_active[@]}"; do
record_finding "unexpected retained security ruleset '${security_name}' requires manual review: ${organization}/${repository}"
fi
done
# A retained queue still changes how the branch merges. Report it for review;
# the publisher alone retires recognized managed queues.
if ! has_merge_queue "${repository}" &&
jq -e 'any(.[]; .name == "08 - Merge queue")' <<<"${rulesets}" >/dev/null; then
record_finding "unexpected retained merge-queue ruleset requires manual review: ${organization}/${repository}"
fi
branch=$(jq -r '.default_branch' "${repository_defaults}")
audit_legacy_protection "${repository}" "${branch}" "${metadata_file}"
while IFS= read -r maintenance; do
Expand Down Expand Up @@ -572,6 +592,11 @@ for repository in "${!expected_active[@]}"; do
fi
expected_checks=$(jq -c --arg repository "${repository}" \
'(.required_ci[$repository] // []) | map({context: ., integration_id: 15368})' "${repositories_config}")
# The merge queue tests the merged result instead of requiring a current branch.
expected_strict=true
if has_merge_queue "${repository}"; then
expected_strict=false
fi
dependency_required=false
if jq -e --arg repository "${repository}" '.dependency_review | index($repository) != null' \
"${root}/config/security-merge-protection.json" >/dev/null; then
Expand Down Expand Up @@ -600,17 +625,18 @@ for repository in "${!expected_active[@]}"; do
"${root}/config/rulesets/dependency-review.json" "${rulesets}" "${metadata_file}"
fi
fi
if ! jq -e --argjson checks "${expected_checks}" '
if ! jq -e --argjson checks "${expected_checks}" --argjson strict "${expected_strict}" '
([.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[]] |
unique_by(.context, .integration_id) | sort_by(.context, .integration_id)) ==
($checks | sort_by(.context, .integration_id)) and
all(.[] | select(.type == "required_status_checks"); .parameters.strict_required_status_checks_policy == true)
all(.[] | select(.type == "required_status_checks"); .parameters.strict_required_status_checks_policy == $strict)
' <<<"${effective}" >/dev/null; then
record_finding "effective required CI contexts or app drift: ${organization}/${repository}"
fi
desired_effective=$(jq --argjson checks "${expected_checks}" '
desired_effective=$(jq --argjson checks "${expected_checks}" --argjson strict "${expected_strict}" '
if ($checks | length) == 0 then []
else .rules | map(.parameters.required_status_checks = $checks) end
else .rules | map(.parameters.required_status_checks = $checks |
.parameters.strict_required_status_checks_policy = $strict) end
' "${root}/config/rulesets/required-ci.json")
for policy in default-branch-integrity default-branch-linear-history; do
template="${root}/config/rulesets/${policy}.json"
Expand Down Expand Up @@ -645,6 +671,17 @@ for repository in "${!expected_active[@]}"; do
check_ruleset "${repository}" "03 - Required CI - ${repository}" \
"${root}/config/rulesets/required-ci.json" "${rulesets}" "${metadata_file}"
fi
if has_merge_queue "${repository}"; then
check_ruleset "${repository}" '08 - Merge queue' \
"${root}/config/rulesets/merge-queue.json" "${rulesets}" "${metadata_file}"
desired_effective=$(jq --argjson prior "${desired_effective}" '$prior + .rules' \
"${root}/config/rulesets/merge-queue.json")
if ! jq -e --slurpfile desired "${root}/config/rulesets/merge-queue.json" \
'[.[] | select(.type == "merge_queue") | {type, parameters}] == $desired[0].rules' \
<<<"${effective}" >/dev/null; then
record_finding "effective merge queue drift: ${organization}/${repository}"
fi
fi
# Parent listings can contain correctly scoped but nonapplicable rulesets.
# Inspect the exact managed name on nonrelease repositories too, so widening
# its complete scope fails while foreign, unmanaged tag rules remain unowned.
Expand Down
Loading
Loading