diff --git a/CI.md b/CI.md index ccdb06f..3d0d910 100644 --- a/CI.md +++ b/CI.md @@ -110,6 +110,17 @@ requests directly. Run the full tier on its branch first with `gh pr merge --merge --admin`. Apart from an incident (below), nothing else bypasses the queue. +`ci-mode` keeps such a pull request out of the queue, so nobody has to +remember it. A pull request must land as a merge commit when it contains a +merge whose first parent is on the base and whose second parent is not: the +merge brings in a line of history that a squash would drop. `ci-mode` fails +the merge group of such a pull request before any test job starts, and the +queue removes it. Merging the base into a branch, or two copies of a branch +into each other, is not such a merge. A pull request stacked on a merge pull +request that has not landed yet is refused too, until the merge lands. When +the check can't run, the merge group fails: queueing again costs one build, +and a squashed merge costs a repair merge. + ## Urgent changes An urgent fix jumps the queue: it goes to the front, is built next on top of diff --git a/actions/ci-mode/action.yml b/actions/ci-mode/action.yml index 2a4692d..330893e 100644 --- a/actions/ci-mode/action.yml +++ b/actions/ci-mode/action.yml @@ -1,8 +1,9 @@ name: CI mode description: >- - 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`. + Decides whether this run executes the full CI tier or only the fast one, + and fails the merge group of a pull request that must land as a merge + commit. See CI.md in cocoindex-io/.github for the tiers. The calling job + needs `checks: read` and `contents: read`. outputs: full: description: "'true' when this run executes the full tier, 'false' for the fast tier." @@ -22,6 +23,9 @@ 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 }} + SERVER_URL: ${{ github.server_url }} + GROUP_REF: ${{ github.event.merge_group.head_ref }} + GROUP_BASE: ${{ github.event.merge_group.base_sha }} run: | full=true reason="a $EVENT run executes the full tier" @@ -50,6 +54,44 @@ 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, and a squash keeps only a merge's first + # parent. A pull request that merges another line of history onto + # its base, such as an upstream merge, must land as a merge commit + # (CI.md, Merge commits), so its merge group fails here, before any + # test job starts, and the queue removes it. When the check can't + # run, the merge group fails too: queueing again costs one build, + # and a squashed merge costs a repair merge. + pr=${GROUP_REF##*/pr-} + pr=${pr%%-*} + if ! [[ $pr =~ ^[0-9]+$ ]]; then + echo "::error::Could not find the pull request number in the merge group ref $GROUP_REF." + exit 1 + fi + # Commits only: the check reads no trees or files. + history="$RUNNER_TEMP/ci-mode-history.git" + git init --quiet --bare "$history" + git --git-dir="$history" remote add origin "$SERVER_URL/$REPO" + auth="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$GH_TOKEN" | base64 | tr -d '\n')" + if ! git --git-dir="$history" -c http.extraheader="$auth" fetch --quiet --no-tags \ + --filter=tree:0 origin "$GROUP_BASE" "+refs/pull/$pr/head:refs/pr"; then + echo "::error::Could not fetch the history of #$pr, so its merge group fails. Queue it again." + exit 1 + fi + # A merge whose first parent is on the base and whose second is not + # brings in a line of history that only a merge commit keeps. + if ! merges=$(git --git-dir="$history" rev-list --merges --parents "$GROUP_BASE..refs/pr"); then + echo "::error::Could not list the merges of #$pr, so its merge group fails. Queue it again." + exit 1 + fi + while read -r merge first second _; do + [ -n "$merge" ] || continue + if git --git-dir="$history" merge-base --is-ancestor "$first" "$GROUP_BASE" \ + && ! git --git-dir="$history" merge-base --is-ancestor "$second" "$GROUP_BASE"; then + echo "::error::#$pr merges ${second:0:12} onto its base in ${merge:0:12}, and the queue's squash would drop that parent. Land #$pr as a merge commit around the queue, after a full run on its branch passes (CI.md, Merge commits)." + exit 1 + fi + done <<<"$merges" fi echo "full=$full" >> "$GITHUB_OUTPUT" tier=fast diff --git a/scripts/apply_rulesets.py b/scripts/apply_rulesets.py index 7d08876..5db8ea3 100644 --- a/scripts/apply_rulesets.py +++ b/scripts/apply_rulesets.py @@ -146,12 +146,16 @@ def main() -> int: 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()) + # A merge-queue rule may only name a merge method the repository allows, + # so methods are enabled before the rulesets and disabled after them. + enabling = {key: value for key, value in settings.items() if key.startswith("allow_") and value is True} for repo in repos: + scope = f"repos/{args.org}/{repo}" + if not args.check: + ok = sync_settings(scope, enabling, check=False) and ok 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 + ok = sync_ruleset(scope, json.loads(path.read_text()), args.check) and ok + ok = sync_settings(scope, settings, args.check) and ok return 0 if ok else 1