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
69 changes: 43 additions & 26 deletions CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,7 @@ workflow has all of them.
3. **Drafts skip.** Every job has `github.event.pull_request.draft != true`
in its `if`, or needs a job that has it.
4. **A `changes` job** runs `cocoindex-io/.github/actions/ci-mode@v1` (it
needs `checks: read`, and `pull-requests: read` in a repository with a
merge queue) and the path filter. Expensive jobs run when
needs `checks: read`) and the path filter. Expensive jobs run when
`full == 'true'` and the filter output is not `'false'`. The filter is
skipped for a stack layer, and so is any other diff against the pull
request's base branch: a stack layer's base is the layer below it, but
Expand All @@ -69,9 +68,9 @@ required `name (value)` check from it never arrives.

## The merge queue

The `merge-queue` repository ruleset configures it: squash merges, every entry must
pass (`ALLGREEN`), and at most 4 builds at once. That build limit is the CI
quota: a queued pull request waits for a free build slot.
The `merge-queue` repository ruleset configures it: merge commits, every
entry must pass (`ALLGREEN`), and at most 4 builds at once. That build limit
is the CI quota: a queued pull request waits for a free build slot.

**How it builds.** Each queued pull request gets its own build of the base
branch plus every pull request ahead of it. Builds run in parallel up to the
Expand Down Expand Up @@ -104,19 +103,16 @@ requests go to the queue on their own, which tests them together anyway.

## Merge commits

The queue squashes. A pull request that must land as a merge commit, such as
an upstream merge, bypasses the queue: repository admins may merge pull
requests directly. Run the full tier on its branch first with
`gh workflow run`, wait for it to pass, then merge with
`gh pr merge --merge --admin`. Apart from an incident (below), nothing else
bypasses the queue.
The queue lands every pull request as a merge commit, and a repository with a
queue allows no other merge method. A pull request keeps its own history, so
an upstream merge keeps its upstream parent and goes through the queue like
any other change. No merge method can squash it, and nothing bypasses the
queue for it.

Label such a pull request `merge-commit` when you open it. `ci-mode` fails
the merge group of a pull request with that label before any test runs, so
the queue removes it instead of squashing it. When `ci-mode` can't read the
labels, the merge group fails too: queueing a pull request again costs one
build, and a squashed merge commit costs a repair merge. The label has to be
on the pull request before it enters the queue.
The default branch's first-parent history has one commit per pull request,
titled with the pull request's title and number. Read it with
`git log --first-parent`, bisect with `git bisect start --first-parent`, and
revert a pull request with `git revert -m 1 <merge-commit>`.

## Urgent changes

Expand All @@ -131,7 +127,7 @@ gh api graphql -F id="$(gh pr view <number> --json id --jq .id)" \
-f query='mutation($id:ID!){enqueuePullRequest(input:{pullRequestId:$id, jump:true}){mergeQueueEntry{position}}}'
```

Bypassing the queue (`gh pr merge --squash --admin`) lands a change without
Bypassing the queue (`gh pr merge --merge --admin`) lands a change without
its full CI run. It is only for an incident in which CI itself is broken.
The Change Management Policy requires green CI even for a hotfix, so such a
bypass is a deviation and goes in the incident log.
Expand All @@ -154,14 +150,18 @@ A repository opts in by setting its `ci-tier` custom property to
repository and which turns the queue on. GitHub's organization rulesets
don't accept a merge-queue rule.

Other required checks stay in the repository's own rulesets. Both rulesets
let repository admins merge pull requests directly, for merge commits and
incidents. To leave the queue, unset the property and delete the
repository's `merge-queue` ruleset.

- Apply or check the property and rulesets: `scripts/apply_rulesets.py
[--check]`. It needs an organization owner with the `admin:org` scope, and
the Change Management Policy asks that the change be announced.
The script also sets the repository's merge settings from
`rulesets/repo-settings.json`: merge commits only, titled with the pull
request's title. Other required checks stay in the repository's own
rulesets. Both rulesets let repository admins merge pull requests directly,
for incidents. To leave the queue, unset the property, delete the
repository's `merge-queue` ruleset, and set its merge settings as it wants
them.

- Apply or check the property, rulesets and merge settings:
`scripts/apply_rulesets.py [--check]`. It needs an organization owner with
the `admin:org` scope, and the Change Management Policy asks that the
change be announced.
- Check a repository locally:
`uv run --with pyyaml scripts/check_ci_conformance.py PATH --repo OWNER/NAME`.
- A ruleset runs a required workflow only on `opened`, `synchronize` and
Expand Down Expand Up @@ -198,3 +198,20 @@ Checked on 2026-10-22 in a temporary private repository:
commit with both parents.
- GitHub's organization rulesets reject a `merge_queue` rule; repository
rulesets accept it.

Checked on 2026-10-24 in cocoindex-plus, after its queue moved to merge
commits:

- An upstream merge (cocoindex-plus#565) went through the queue and landed
as the merge commit its merge group tested. Its second parent is the pull
request's head, the upstream merge itself, so the upstream commit stayed
an ancestor of `main`.
- The queue's merge commit takes the repository's merge settings: the pull
request's title and number as its subject, the pull request's body as its
message.
- With squash and rebase merges disallowed, `gh pr merge --squash --admin`
and `--rebase --admin` fail: "Squash merges are not allowed on this
repository."
- The queue marks a failed entry unmergeable at once, but removes it only
when it reaches the front. Until then, the entries behind it are built on
top of it.
28 changes: 2 additions & 26 deletions actions/ci-mode/action.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
name: CI mode
description: >-
Decides whether this run executes the full CI tier or only the fast one,
and fails a merge group whose pull request must land as a merge commit.
Decides whether this run executes the full CI tier or only the fast one.
See CI.md in cocoindex-io/.github for the tiers. The calling job needs
`checks: read` and `contents: read`, and in a repository with a merge
queue also `pull-requests: read`.
`checks: read` and `contents: read`.
outputs:
full:
description: "'true' when this run executes the full tier, 'false' for the fast tier."
Expand All @@ -24,7 +22,6 @@ runs:
BASE: ${{ github.event.pull_request.stack.base.ref || github.event.pull_request.base.ref }}
STACK_POSITION: ${{ github.event.pull_request.stack.position }}
STACK_SIZE: ${{ github.event.pull_request.stack.size }}
HEAD_REF: ${{ github.event.merge_group.head_ref }}
run: |
full=true
reason="a $EVENT run executes the full tier"
Expand Down Expand Up @@ -53,27 +50,6 @@ runs:
else
echo "::warning::Could not read the checks of $SHA, so this run executes the full tier."
fi
elif [ "$EVENT" = merge_group ]; then
# The queue squashes. A pull request labelled merge-commit, such as
# an upstream merge, must keep its merge commit, so its merge group
# fails here, before any expensive job starts, and the queue removes
# it. A failed lookup fails too: queueing a pull request again costs
# one build, and a squashed merge commit costs a repair. The queue's
# branch names the pull request: gh-readonly-queue/<base>/pr-<n>-<sha>.
pr=${HEAD_REF##*/pr-}
pr=${pr%%-*}
if ! [[ $pr =~ ^[0-9]+$ ]]; then
echo "::error::Could not find the pull request number in the merge group ref $HEAD_REF."
exit 1
fi
if ! labelled=$(gh api "repos/$REPO/pulls/$pr" --jq 'any(.labels[]; .name == "merge-commit")'); then
echo "::error::Could not read the labels of #$pr, so its merge group fails. Queue it again; if this repeats, check that the job that runs ci-mode has \`pull-requests: read\`."
exit 1
fi
if [ "$labelled" = true ]; then
echo "::error::#$pr is labelled merge-commit, so it must land as a merge commit, and the queue squashes. Merge it around the queue after a full run on its branch passes: gh pr merge $pr --merge --admin (CI.md, Merge commits)."
exit 1
fi
fi
echo "full=$full" >> "$GITHUB_OUTPUT"
tier=fast
Expand Down
7 changes: 7 additions & 0 deletions rulesets/repo-settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"allow_merge_commit": true,
"allow_squash_merge": false,
"allow_rebase_merge": false,
"merge_commit_title": "PR_TITLE",
"merge_commit_message": "PR_BODY"
}
2 changes: 1 addition & 1 deletion rulesets/repo/merge-queue.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
{
"type": "merge_queue",
"parameters": {
"merge_method": "SQUASH",
"merge_method": "MERGE",
"grouping_strategy": "ALLGREEN",
"max_entries_to_build": 4,
"max_entries_to_merge": 4,
Expand Down
22 changes: 22 additions & 0 deletions scripts/apply_rulesets.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
- rulesets/repo/*.json: repository rulesets, put on every repository whose
`ci-tier` is `merge-queue`. GitHub doesn't accept a merge-queue rule in an
organization ruleset, so the queue lives here.
- rulesets/repo-settings.json: the merge settings of those repositories. They
allow only the merge method the queue uses.

Needs the `gh` CLI signed in as an organization owner with the admin:org scope
(`gh auth refresh -h github.com -s admin:org`). Org-setting changes follow the
Expand All @@ -28,6 +30,7 @@

ROOT = pathlib.Path(__file__).resolve().parent.parent / "rulesets"
PROPERTY_FILE = ROOT / "ci-tier-property.json"
SETTINGS_FILE = ROOT / "repo-settings.json"
OPT_IN_VALUE = "merge-queue"


Expand Down Expand Up @@ -114,6 +117,21 @@ def sync_ruleset(scope: str, want: dict[str, Any], check: bool) -> bool:
return True


def sync_settings(repo: str, want: dict[str, Any], check: bool) -> bool:
"""Make the settings of `repo` (repos/<owner>/<name>) that `want` names match it."""
label = f"{repo} settings"
found = differences(want, gh(repo))
if not found:
print(f"{label}: up to date")
return True
print(f"{label}: " + "; ".join(found))
if check:
return False
gh("-X", "PATCH", repo, payload=want)
print(f"{label}: applied")
return True


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--org", default="cocoindex-io")
Expand All @@ -126,9 +144,13 @@ def main() -> int:
ok = sync_ruleset(f"orgs/{args.org}", json.loads(path.read_text()), args.check) and ok
repos = opted_in(gh_list(f"orgs/{args.org}/properties/values"), prop["property_name"])
print(f"repositories with {prop['property_name']}={OPT_IN_VALUE}: {', '.join(repos) or 'none'}")
settings = json.loads(SETTINGS_FILE.read_text())
for repo in repos:
for path in sorted((ROOT / "repo").glob("*.json")):
ok = sync_ruleset(f"repos/{args.org}/{repo}", json.loads(path.read_text()), args.check) and ok
# After the rulesets: the queue's merge method has to stay allowed
# while the ruleset still names the old one.
ok = sync_settings(f"repos/{args.org}/{repo}", settings, args.check) and ok
return 0 if ok else 1


Expand Down
4 changes: 2 additions & 2 deletions scripts/test_apply_rulesets.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@


def test_subset_compare_ignores_extra_keys() -> None:
want = {"name": "x", "rules": [{"type": "merge_queue", "parameters": {"merge_method": "SQUASH"}}]}
have = {"name": "x", "id": 1, "rules": [{"type": "merge_queue", "parameters": {"merge_method": "SQUASH", "extra": 1}}]}
want = {"name": "x", "rules": [{"type": "merge_queue", "parameters": {"merge_method": "MERGE"}}]}
have = {"name": "x", "id": 1, "rules": [{"type": "merge_queue", "parameters": {"merge_method": "MERGE", "extra": 1}}]}
assert differences(want, have) == []


Expand Down
Loading