From 681c95f3596eb4e462addd748f377ea8aa7edbbc Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 7 Oct 2026 11:08:57 -0700 Subject: [PATCH] ci: add daily main sync for the gateway integration branch (#8401) Merge main into gateway/integration every weekday with a --no-ff merge commit; abort a conflicting merge and open a sync/- pull request listing the conflicting files instead. Run ci.yml on pull requests that target gateway/integration so the branch ruleset's required checks can report, and document both in the CI gate matrix. Refs rustfs/backlog#2736, rustfs/backlog#2734 --- .github/workflows/ci.yml | 4 +- .../workflows/gateway-integration-sync.yml | 197 ++++++++++++++++++ docs/testing/ci-gates.md | 3 + 3 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/gateway-integration-sync.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28b1cd0a2..fe1907458 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,9 @@ on: - "flake.lock" pull_request: types: [ opened, synchronize, reopened, closed ] - branches: [ main ] + # gateway/integration carries main's ruleset (rustfs/backlog#2734), so its + # pull requests must produce the same required checks. + branches: [ main, gateway/integration ] merge_group: types: [ checks_requested ] schedule: diff --git a/.github/workflows/gateway-integration-sync.yml b/.github/workflows/gateway-integration-sync.yml new file mode 100644 index 000000000..f80fbfeb2 --- /dev/null +++ b/.github/workflows/gateway-integration-sync.yml @@ -0,0 +1,197 @@ +# Copyright 2026 RustFS Team +# SPDX-License-Identifier: Apache-2.0 + +name: Gateway Integration Sync + +# Merges `main` into the long-lived `gateway/integration` branch every weekday +# so the s3s -> gateway migration (rustfs/backlog#2734, task T0.1 in #2736) +# never accumulates merge debt. A clean merge lands as a `--no-ff` merge commit +# pushed straight to the branch. A conflicting merge is aborted and surfaced as +# a pull request from `sync/-`, whose head is the un-merged +# source commit, so the conflict is resolved the same day, in review, with no +# other change riding along. The run itself stays green in that case: the pull +# request is the signal. Any other failure (push rejected, pull request not +# created) fails the run and, on the schedule, files the shared failure issue. +# +# Resolving a conflict pull request: merge `origin/gateway/integration` into +# the sync branch, resolve, push, get the review. Land it with a fast-forward +# push of the resolved merge commit to `gateway/integration`, not with the +# squash button: a squash drops the merge ancestry and the same hunks conflict +# again on the next run. +# +# Write scope: only `gateway/integration` and `sync/*` are ever pushed, and +# both push targets below are literal refs. The pushes use a one-hour +# installation token of the organisation's own GitHub App (contents: write) +# because that app is the only non-admin bypass actor of the +# `gateway/integration` ruleset; GITHUB_TOKEN cannot be a ruleset bypass actor, +# so it cannot push through the required reviews and checks. The conflict pull +# request is opened with FLAKE_UPDATE_TOKEN, the PAT that already opens the +# weekly flake.lock pull requests, because this repository keeps "Allow GitHub +# Actions to create and approve pull requests" switched off. GITHUB_TOKEN is +# therefore read-only here. +# +# `schedule` fires only from the default branch, so the cron is live once this +# file exists on `main`. `workflow_dispatch` runs from any ref that carries the +# file and always acts on `gateway/integration`. + +on: + schedule: + - cron: "0 2 * * 1-5" # Weekdays 02:00 UTC + workflow_dispatch: + inputs: + source_ref: + description: >- + Branch to merge into gateway/integration. The schedule always merges + main; only the conflict acceptance test (rustfs/backlog#2736) sets + another branch. + required: false + default: main + type: string + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +env: + TARGET_BRANCH: gateway/integration + +jobs: + sync: + name: Merge main into gateway/integration + if: github.repository == 'rustfs/rustfs' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Create the branch push token + id: push-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + app-id: ${{ vars.CLA_BOT_APP_ID }} + private-key: ${{ secrets.CLA_BOT_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: rustfs + permission-contents: write + + - name: Checkout gateway/integration + uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7 + with: + # persist-credentials-exempt: this checkout's token IS the push + # credential — the merge commit and the sync branch are pushed with it + # below. It is a one-hour installation token scoped to contents. + ref: gateway/integration + fetch-depth: 0 + token: ${{ steps.push-token.outputs.token }} + + - name: Configure the bot identity + run: | + git config user.name "rustfs-cla-assistant[bot]" + git config user.email "273121464+rustfs-cla-assistant[bot]@users.noreply.github.com" + + - name: Merge the source branch + id: merge + env: + SOURCE_REF: ${{ inputs.source_ref || 'main' }} + run: | + set -euo pipefail + if ! [[ "$SOURCE_REF" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] \ + || [[ "$SOURCE_REF" == *..* ]] \ + || [[ "$SOURCE_REF" == "$TARGET_BRANCH" ]]; then + echo "::error::refusing source_ref '${SOURCE_REF}'" + exit 1 + fi + git fetch origin "refs/heads/${SOURCE_REF}:refs/remotes/origin/${SOURCE_REF}" + source_sha=$(git rev-parse "origin/${SOURCE_REF}") + target_sha=$(git rev-parse HEAD) + { + echo "source_ref=${SOURCE_REF}" + echo "source_sha=${source_sha}" + echo "target_sha=${target_sha}" + } >> "$GITHUB_OUTPUT" + if git merge --no-ff --no-edit "origin/${SOURCE_REF}"; then + if [[ "$(git rev-parse HEAD)" == "$target_sha" ]]; then + echo "result=up-to-date" >> "$GITHUB_OUTPUT" + echo "\`${TARGET_BRANCH}\` already contains \`${SOURCE_REF}\` (${source_sha}); nothing to push." >> "$GITHUB_STEP_SUMMARY" + else + echo "result=merged" >> "$GITHUB_OUTPUT" + fi + exit 0 + fi + git diff --name-only --diff-filter=U > "$RUNNER_TEMP/conflicts.txt" + git merge --abort + if [[ ! -s "$RUNNER_TEMP/conflicts.txt" ]]; then + echo "::error::git merge failed without reporting conflicting paths; this is not a conflict" + exit 1 + fi + echo "result=conflict" >> "$GITHUB_OUTPUT" + + - name: Push the merge commit + if: steps.merge.outputs.result == 'merged' + env: + SOURCE_REF: ${{ steps.merge.outputs.source_ref }} + SOURCE_SHA: ${{ steps.merge.outputs.source_sha }} + run: | + set -euo pipefail + git push origin HEAD:refs/heads/gateway/integration + echo "Merged \`${SOURCE_REF}\` (${SOURCE_SHA}) into \`${TARGET_BRANCH}\` as $(git rev-parse HEAD)." >> "$GITHUB_STEP_SUMMARY" + + - name: Open the conflict pull request + if: steps.merge.outputs.result == 'conflict' + env: + GH_TOKEN: ${{ secrets.FLAKE_UPDATE_TOKEN }} + SOURCE_REF: ${{ steps.merge.outputs.source_ref }} + SOURCE_SHA: ${{ steps.merge.outputs.source_sha }} + TARGET_SHA: ${{ steps.merge.outputs.target_sha }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + date_utc=$(date -u +%Y-%m-%d) + sync_branch="sync/${SOURCE_REF//\//-}-${date_utc}" + git push origin "refs/remotes/origin/${SOURCE_REF}:refs/heads/${sync_branch}" + { + echo "The scheduled merge of \`${SOURCE_REF}\` into \`${TARGET_BRANCH}\` stopped on conflicts and was aborted; \`${TARGET_BRANCH}\` is unchanged." + echo + echo "This branch is the un-merged \`${SOURCE_REF}\` head. Merge \`origin/${TARGET_BRANCH}\` into it, resolve the files below, push, and get the review. Land the resolved merge commit with a fast-forward push to \`${TARGET_BRANCH}\`; a squash merge drops the merge ancestry and the same hunks conflict again on the next run. Do not include any change that is not conflict resolution." + echo + echo "- Run: ${RUN_URL}" + echo "- Source: \`${SOURCE_REF}\` at ${SOURCE_SHA}" + echo "- Target: \`${TARGET_BRANCH}\` at ${TARGET_SHA}" + echo + echo "## Conflicting files" + echo + echo "Output of \`git diff --name-only --diff-filter=U\` captured before \`git merge --abort\`:" + echo + sed 's/^/- /' "$RUNNER_TEMP/conflicts.txt" + } > "$RUNNER_TEMP/pr-body.md" + title="sync: merge ${SOURCE_REF} ${date_utc} (conflicts)" + existing=$(gh pr list --repo "$GITHUB_REPOSITORY" --base "$TARGET_BRANCH" --head "$sync_branch" --state open --json number --jq '.[0].number // empty') + if [[ -n "$existing" ]]; then + gh pr edit "$existing" --repo "$GITHUB_REPOSITORY" --title "$title" --body-file "$RUNNER_TEMP/pr-body.md" + pr_url=$(gh pr view "$existing" --repo "$GITHUB_REPOSITORY" --json url --jq .url) + else + pr_url=$(gh pr create --repo "$GITHUB_REPOSITORY" --base "$TARGET_BRANCH" --head "$sync_branch" --title "$title" --body-file "$RUNNER_TEMP/pr-body.md") + fi + echo "Conflicts in \`${SOURCE_REF}\` -> \`${TARGET_BRANCH}\`; pull request: ${pr_url}" >> "$GITHUB_STEP_SUMMARY" + + alert-on-failure: + name: Alert on scheduled failure + needs: [sync] + # Same ci-8 mechanism audit.yml and coverage.yml use: a scheduled run that + # could not push or could not open the conflict pull request must not stay + # visible only in the Actions tab. + if: github.repository == 'rustfs/rustfs' && (always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure')) + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + issues: write + steps: + - uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7 + with: + persist-credentials: false + - name: Open or update failure-tracking issue + uses: ./.github/actions/schedule-failure-issue + with: + github-token: ${{ secrets.BACKLOG_ISSUE_TOKEN }} diff --git a/docs/testing/ci-gates.md b/docs/testing/ci-gates.md index cbbdb4867..29cc67db4 100644 --- a/docs/testing/ci-gates.md +++ b/docs/testing/ci-gates.md @@ -21,6 +21,8 @@ PRs changing only Rust sources under `crates/e2e_test/src/` and known E2E select `required-checks` runs even after failed or skipped dependencies. `scripts/ci_gate.py verify` rejects missing jobs, unexpected jobs, failure, cancellation, and unexpected skips; optional lanes are required only on their declared events. `Workspace Test and Lint` is the ordinary Rust job, while `Test and Lint` uniquely names the aggregate. New validation jobs must update both its direct dependencies and the script contract. Test this wiring and its failure cases with `python3 scripts/ci_gate.py --self-test`. +The `gateway/integration` ruleset (`24663286`) requires the same three contexts with the same review rules for the s3s to gateway integration branch (rustfs/backlog#2734). It omits `required_linear_history` because `gateway-integration-sync.yml` lands `--no-ff` merge commits from `main` on that branch every weekday, and it names the `rustfs-cla-assistant` app as the bypass actor that pushes them. `ci.yml` runs on pull requests targeting either branch. + Verify the live rule before changing merge policy: ```bash @@ -100,6 +102,7 @@ Scheduled lanes never block a PR. Their workflow-local gate fails the run, sched | `e2e-upgrade.yml` (weekly) | `upgrade` (4-case matrix) | upgrade and rollback gate; server logs | no | see the PR row | | `oidc-keycloak.yml` (weekly) | `oidc-keycloak-live` | live OIDC gate | no | see the PR row | | `targets-integration.yml` (nightly) | `targets-live` | live target gate; container logs | no | see the PR row | +| `gateway-integration-sync.yml` (weekdays) | `sync` | merges `main` into `gateway/integration` as a `--no-ff` merge commit; a conflicting merge is aborted and opens a `sync/-` pull request listing the conflicting files, which keeps the run green; a rejected push or missing pull request fails it | no | dispatch, optionally with `source_ref` | | `scheduled-validation-freshness.yml` (nightly) | `check-freshness` | fails on missing or stale attempts or completed successes | n/a | dispatch | Manual `workflow_dispatch` runs are debugging evidence and do not open scheduled-failure issues. A manual performance run may explicitly allow a known regression; that override is not a passing baseline.