This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-8379-75c85aa7e771d9b3d3c79396b08b196928238858 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 069cd208c68d3f3a8e50d583a0a0ff7e00a4e536 Author: Meng Wang <[email protected]> AuthorDate: Fri Sep 11 14:11:59 2026 +0000 ci: give GitHub Actions bypass on the release-branch ruleset (#8379) ### What changes were proposed in this PR? The Merge Queue ruleset requires every change into `release/*` to arrive as a PR with one approving review, green required checks, and a pass through the merge queue. Right for people — but it also blocks `direct-backport-push.yml`, whose fast path pushes clean cherry-picks; every such push has been rejected since 2026-07-24, and five backports were silently lost (#8377). This splits the ruleset in two, rule-for-rule identical: `Merge Queue` keeps `~DEFAULT_BRANCH`, and a new `Merge Queue (release)` carries the three release branches plus a `bypass_actors` entry for the GitHub Actions app (`actor_id: 15368`). The split exists because a bypass is ruleset-wide — kept in one ruleset, it would let workflows push `main` too. Scope, stated precisely: the bypass exempts actions performed as the Actions app — any workflow's `GITHUB_TOKEN`, not just the backport workflow, since rulesets cannot scope a bypass to one workflow. People and PATs still face every rule on every branch; `main` gets no bypass; force pushes and branch deletion stay blocked for everyone, Actions included, by `Default Branch Protection`. Ordering inside the file is load-bearing: asfyaml applies rulesets in file order, so `Merge Queue (release)` is created before `Merge Queue` stops covering the release branches. If GitHub rejects the new ruleset, the apply aborts with today's protections fully intact — no failure path leaves the release branches uncovered. The bypass alone would not revive the fast path: since #4676 the push job checked out with `AUTO_MERGE_TOKEN`, so GitHub evaluated its pushes as that PAT's owner — every pre-ruleset direct push shows a person as the pusher — and an Actions-app bypass would not cover them. The push job now uses the default `GITHUB_TOKEN`, which the bypass does cover, and dispatches `Required Checks` on the pushed branch explicitly, since a `GITHUB_TOKEN` push starts no push-triggered runs while `workflow_dispatch` is the documented exception that always creates one. The conflict path keeps the PAT: it pushes unprotected `backport/*` branches, where the opened PR's CI must still trigger. ### Any related issues, documentation, discussions? Closes #8377. #8378 took the PR-plus-auto-merge route to the same problem and is closed in favor of trying the bypass first. What lands on a release branch through this path is still only a cherry-pick of a commit that passed main's full CI and, once #8096 lands, its release manager's approving review. ### How was this PR tested? `.asf.yaml` and the workflows parse, and the structural check is now committed instead of run once: `.github/scripts/test_asf_rulesets.sh` (picked up by build.yml's glob-discovered infra tests) asserts the two rulesets' `rules` blocks stay deep-equal and that `.asf.yaml` and every workflow parse under a duplicate-key-strict loader, with PyYAML pinned in `amber/dev-requirements.txt` — the file the infra job installs; every failure path (duplicate key, rules drift, bypass on main, bypass tampered, ruleset reorder, missing PyYAML) was verified red before trusting the green. asfyaml treats a ruleset carrying `target`/`rules`/`bypass_actors` as a raw payload and forwards it verbatim (`_RAW_RULESET_KEYS` in `feature/github/rulesets.py`; its upstream tests assert the POST payload carries `bypass_actors`). What cannot be proven before merge is GitHub accepting the Actions app as a bypass actor on this org: the same payload on a personal repository is rejected with "Actor GitHub Actions integration must be part of the ruleset source or owner organization", and no ASF repository uses an Integration bypass actor yet — hence the fail-safe ordering above. After Infra applies the merged file, `GET /repos/apache/texera/rulesets` should list `Merge Queue (release)`; if it does not, the apply failed closed and nothing changed. The next clean backport is the end-to-end test. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (claude-fable-5) --- .asf.yaml | 60 +++++++++++++- .github/scripts/test_asf_rulesets.sh | 122 +++++++++++++++++++++++++++++ .github/workflows/direct-backport-push.yml | 36 +++++++-- .github/workflows/required-checks.yml | 2 + amber/dev-requirements.txt | 1 + 5 files changed, 214 insertions(+), 7 deletions(-) diff --git a/.asf.yaml b/.asf.yaml index 42316db44a..7e4f86a375 100644 --- a/.asf.yaml +++ b/.asf.yaml @@ -69,20 +69,76 @@ github: rebase: false rulesets: - - name: Merge Queue + # Rule-for-rule identical to "Merge Queue" below; split out so the bypass + # here stays off main. The bypass exempts actions performed as the GitHub + # Actions app — i.e. any workflow's GITHUB_TOKEN, which is what + # direct-backport-push.yml's fast path pushes with (#8377). It cannot be + # scoped to a single workflow. People and PATs still face every rule. + # + # Listed BEFORE "Merge Queue" deliberately: asfyaml applies rulesets in + # file order, so this one is created before that one stops covering the + # release branches. If GitHub rejects this ruleset, the apply aborts with + # the old protections fully intact; the failure order never leaves the + # release branches uncovered. + - name: "Merge Queue (release)" target: branch enforcement: active conditions: ref_name: exclude: [] include: - - "~DEFAULT_BRANCH" # Merge queue rules do NOT support wildcard ref patterns, so # release branches must be listed explicitly (not release/*). # Add each release line here as it is cut. - "refs/heads/release/v1.1" - "refs/heads/release/v1.2" - "refs/heads/release/v1.3" + bypass_actors: + # The GitHub Actions app. + - actor_id: 15368 + actor_type: Integration + bypass_mode: always + rules: + - type: deletion + - type: non_fast_forward + - type: merge_queue + parameters: + merge_method: SQUASH + max_entries_to_build: 2 + min_entries_to_merge: 2 + max_entries_to_merge: 5 + min_entries_to_merge_wait_minutes: 3 + grouping_strategy: HEADGREEN + check_response_timeout_minutes: 45 + - type: pull_request + parameters: + allowed_merge_methods: + - squash + dismiss_stale_reviews_on_push: false + require_code_owner_review: false + require_last_push_approval: false + required_approving_review_count: 1 + required_review_thread_resolution: true + - type: required_linear_history + - type: required_status_checks + parameters: + strict_required_status_checks_policy: false + required_status_checks: + - context: Required Checks + - context: Check License Headers + - context: Validate PR title + + - name: Merge Queue + target: branch + enforcement: active + conditions: + ref_name: + exclude: [] + include: + # Release branches carry these same rules in "Merge Queue + # (release)" above — a separate ruleset because its Actions + # bypass must not extend to main. + - "~DEFAULT_BRANCH" rules: - type: deletion - type: non_fast_forward diff --git a/.github/scripts/test_asf_rulesets.sh b/.github/scripts/test_asf_rulesets.sh new file mode 100755 index 0000000000..66bd29c3dc --- /dev/null +++ b/.github/scripts/test_asf_rulesets.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# Invariants over the CI configuration that a plain YAML parse cannot see. +# +# 1. "Merge Queue" and "Merge Queue (release)" in .asf.yaml must carry +# identical rules: they are one policy split across two rulesets only so +# the release half can hold an Actions bypass that must not reach main. +# Nothing else keeps the copies from drifting apart. +# 2. .asf.yaml and every workflow must parse with a duplicate-key-strict +# loader. PyYAML silently keeps the last duplicate, but GitHub's loader +# rejects the file, so a duplicated trigger key passes local checks and +# then stops the workflow from ever starting. + +set -uo pipefail + +command -v python3 >/dev/null || { echo "python3 is required to run these tests" >&2; exit 1; } +# Runners ship python3 but not necessarily PyYAML (see release_branches.py); +# CI installs it via amber/dev-requirements.txt. +python3 -c 'import yaml' 2>/dev/null || { echo "PyYAML is required (pip install pyyaml)" >&2; exit 1; } + +cd "$(git rev-parse --show-toplevel)" + +python3 - <<'EOF' +import glob +import sys + +import yaml + + +class StrictLoader(yaml.SafeLoader): + pass + + +def no_duplicates(loader, node, deep=False): + seen = set() + for key_node, _ in node.value: + key = loader.construct_object(key_node, deep=deep) + if key in seen: + raise yaml.YAMLError( + f"duplicate key {key!r} at line {key_node.start_mark.line + 1}" + ) + seen.add(key) + return yaml.SafeLoader.construct_mapping(loader, node, deep) + + +StrictLoader.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, no_duplicates +) + +failures = [] + +files = sorted(glob.glob(".github/workflows/*.yml")) + [".asf.yaml"] +for path in files: + with open(path) as fh: + try: + yaml.load(fh, StrictLoader) + except yaml.YAMLError as exc: + failures.append(f"{path}: {exc}") + +with open(".asf.yaml") as fh: + ruleset_list = [ + r for r in yaml.safe_load(fh)["github"]["rulesets"] if isinstance(r, dict) + ] +rulesets = {r.get("name"): r for r in ruleset_list} +main_rs = rulesets.get("Merge Queue") +release_rs = rulesets.get("Merge Queue (release)") +if main_rs is None or release_rs is None: + failures.append( + ".asf.yaml: expected rulesets named 'Merge Queue' and 'Merge Queue (release)'" + ) +elif main_rs["rules"] != release_rs["rules"]: + failures.append( + ".asf.yaml: 'Merge Queue' and 'Merge Queue (release)' rules differ -- " + "these are one policy in two rulesets; change both or neither" + ) +if main_rs is not None and "bypass_actors" in main_rs: + failures.append( + ".asf.yaml: 'Merge Queue' must not carry bypass_actors -- " + "keeping the bypass off main is what the split exists for" + ) +ACTIONS_APP = [{"actor_id": 15368, "actor_type": "Integration", "bypass_mode": "always"}] +if release_rs is not None and release_rs.get("bypass_actors") != ACTIONS_APP: + failures.append( + ".asf.yaml: 'Merge Queue (release)' bypass_actors must be exactly the " + "GitHub Actions app -- widen this list and the test together, deliberately" + ) +names = [r.get("name") for r in ruleset_list] +if main_rs is not None and release_rs is not None and names.index( + "Merge Queue (release)" +) > names.index("Merge Queue"): + failures.append( + ".asf.yaml: 'Merge Queue (release)' must be listed before 'Merge Queue' -- " + "asfyaml applies rulesets in file order, and creating the release ruleset " + "before shrinking the main one is what keeps a rejected run fail-safe" + ) + +for failure in failures: + print(f"FAIL: {failure}") +if failures: + sys.exit(1) +print( + f"OK: {len(files)} files duplicate-key clean; " + "Merge Queue rules identical; bypass only on the release ruleset; " + "release ruleset listed first" +) +EOF diff --git a/.github/workflows/direct-backport-push.yml b/.github/workflows/direct-backport-push.yml index b67fcc90fc..149c608556 100644 --- a/.github/workflows/direct-backport-push.yml +++ b/.github/workflows/direct-backport-push.yml @@ -342,6 +342,15 @@ jobs: needs: discover if: ${{ needs.discover.outputs.has_push == 'true' }} runs-on: ubuntu-latest + permissions: + # Everything this job's steps call, and nothing more: push the + # cherry-pick and comment on the commit (contents), dispatch Required + # Checks (actions), set the per-target commit status (statuses), + # annotate the original PR (issues). + actions: write + contents: write + issues: write + statuses: write name: "backport #${{ matrix.pr_number }} to ${{ matrix.target }}" strategy: fail-fast: false @@ -356,11 +365,12 @@ jobs: uses: actions/checkout@v7 with: fetch-depth: 0 - # Use AUTO_MERGE_TOKEN (fine-grained PAT) so the push to the release - # branch retriggers workflows on that branch. GITHUB_TOKEN-authored - # pushes are excluded from triggering downstream workflows, which - # silences post-merge CI on backport commits. - token: ${{ secrets.AUTO_MERGE_TOKEN || secrets.GITHUB_TOKEN }} + # Push with the default GITHUB_TOKEN: the release rulesets admit the + # GitHub Actions app as a bypass actor, while a PAT-authored push is + # evaluated as that person and rejected. A GITHUB_TOKEN push starts + # no downstream workflows, so the step after the cherry-pick + # dispatches Required Checks itself — workflow_dispatch runs are the + # documented exception that GITHUB_TOKEN may create. - name: Cherry-pick merge commit onto target branch id: cherry_pick env: @@ -571,6 +581,22 @@ jobs: log "new_sha=${new_sha}" echo "new_sha=${new_sha}" >> "$GITHUB_OUTPUT" + - name: Run Required Checks on the pushed release branch + if: success() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TARGET_BRANCH: ${{ matrix.target }} + run: | + # The GITHUB_TOKEN push above starts no push-triggered workflows; + # dispatch the run the release branch would otherwise have gotten. + # Best-effort: the backport itself has landed, so a dispatch + # failure must not demote this job to failed -- the failure + # reporter below would then claim a landed backport was lost. + if ! gh workflow run required-checks.yml \ + --repo "${GITHUB_REPOSITORY}" --ref "${TARGET_BRANCH}"; then + echo "::warning::Could not start Required Checks on ${TARGET_BRANCH}; start it manually from the Actions tab." + fi + - name: Annotate original PR and commit on success if: success() uses: actions/github-script@v9 diff --git a/.github/workflows/required-checks.yml b/.github/workflows/required-checks.yml index 1db8a52668..42b31d37fd 100644 --- a/.github/workflows/required-checks.yml +++ b/.github/workflows/required-checks.yml @@ -30,6 +30,8 @@ on: - labeled - unlabeled merge_group: + # Also dispatched by direct-backport-push.yml after its GITHUB_TOKEN push + # to a release branch, which starts no push-triggered runs. workflow_dispatch: permissions: diff --git a/amber/dev-requirements.txt b/amber/dev-requirements.txt index 848104e776..593c70d130 100644 --- a/amber/dev-requirements.txt +++ b/amber/dev-requirements.txt @@ -42,4 +42,5 @@ textual==8.2.8 # Reads bin/k8s/values.yaml in bin/k8s/tests/test_helm_values.sh. That check fails # rather than skipping when this is missing, so the suite cannot go green by accident. +# Also read by .github/scripts/test_asf_rulesets.sh (infra job shell tests). PyYAML==6.0.2
