From 691b1d8f8fc3a5b6d0f25cca5254e01ed1c2843a Mon Sep 17 00:00:00 2001 From: xiedeyantu Date: Sun, 25 Oct 2026 22:50:27 +0800 Subject: [PATCH 1/3] [CALCITE-7871] Validate pull request titles and require a single matching commit Signed-off-by: xiedeyantu --- .asf.yaml | 56 ++++++++++ .github/PR_CHECKS.md | 45 ++++++++ .github/workflows/pr-single-commit.yml | 102 +++++++++++++++++ .github/workflows/pr-title.yml | 149 +++++++++++++++++++++++++ 4 files changed, 352 insertions(+) create mode 100644 .github/PR_CHECKS.md create mode 100644 .github/workflows/pr-single-commit.yml create mode 100644 .github/workflows/pr-title.yml diff --git a/.asf.yaml b/.asf.yaml index e21b385c64b8..2c7fae2a45b0 100644 --- a/.asf.yaml +++ b/.asf.yaml @@ -28,6 +28,62 @@ github: # We prefer linear history, so creating merge commits is disabled in UI merge: false rebase: true + rulesets: + - name: PR checks + target: branch + enforcement: active + bypass_actors: [] + conditions: + ref_name: + include: [refs/heads/main] + exclude: [] + rules: + - type: required_status_checks + parameters: + strict_required_status_checks_policy: false + # Keep contexts in sync with the check names reported on PRs. + # Jenkins pr-head is deliberately not required. + required_status_checks: + - context: Check PR title + integration_id: 15368 # GitHub Actions + - context: Check single commit + integration_id: 15368 # GitHub Actions + - context: Validation + integration_id: 15368 # GitHub Actions + - context: Windows (JDK 8) + integration_id: 15368 # GitHub Actions + - context: Windows (JDK 17) + integration_id: 15368 # GitHub Actions + - context: Windows (JDK 21) + integration_id: 15368 # GitHub Actions + - context: Linux (JDK 8, oldest Guava, America/New_York Timezone) + integration_id: 15368 # GitHub Actions + - context: Linux (JDK 8, latest Guava, America/New_York Timezone) + integration_id: 15368 # GitHub Actions + - context: Linux (JDK 11, Pacific/Chatham Timezone) + integration_id: 15368 # GitHub Actions + - context: Linux (JDK 17) + integration_id: 15368 # GitHub Actions + - context: Linux (JDK 21) + integration_id: 15368 # GitHub Actions + - context: Linux (JDK 24) + integration_id: 15368 # GitHub Actions + - context: Linux (JDK 11, Avatica main) + integration_id: 15368 # GitHub Actions + - context: macOS (JDK 21) + integration_id: 15368 # GitHub Actions + - context: ErrorProne (JDK 11, latest Guava) + integration_id: 15368 # GitHub Actions + - context: CheckerFramework (JDK 11) + integration_id: 15368 # GitHub Actions + - context: CheckerFramework (JDK 11, oldest Guava) + integration_id: 15368 # GitHub Actions + - context: Druid Tests + integration_id: 15368 # GitHub Actions + - context: SonarCloud + integration_id: 57789 # GitHub Code Scanning + - context: SonarCloud Code Analysis + integration_id: 12526 # SonarQube Cloud notifications: commits: commits@calcite.apache.org issues: issues@calcite.apache.org diff --git a/.github/PR_CHECKS.md b/.github/PR_CHECKS.md new file mode 100644 index 000000000000..67ff629e0cc1 --- /dev/null +++ b/.github/PR_CHECKS.md @@ -0,0 +1,45 @@ + + +# PR checks + +The [ruleset in `.asf.yaml`](../.asf.yaml) requires the 15 CI jobs in +`main.yml`, Gradle wrapper `Validation`, `SonarCloud` (code scanning), +`SonarCloud Code Analysis`, and the two PR metadata checks below to merge +into `main`, with no bypass actors. GitHub enforces these checks directly. +`continuous-integration/jenkins/pr-head` is not required by this ruleset. + +- `Check PR title` requires `[CALCITE-] ` to match the + complete PR title and at least one commit subject exactly. Other prefixes + skip this check. Title edits and pushes rerun it; Jira-only edits need a rerun. +- `Check single commit` runs only with the `Approved` label. It verifies that + the label was applied by someone with write, maintain, or admin access, + then requires exactly one commit. GitHub Approve reviews are separate. + +Existing CI triggers and jobs are unchanged. If a required workflow does +not run (for example, `main.yml` ignores PRs changing only `site/**`), its +missing checks block merging. GitHub accepts success, skipped, and neutral +check conclusions; pending, failed, or cancelled checks block merging. +Slow tests and website builds are not added to the required list. + +When adding or renaming a required check, update its context in `.asf.yaml`. + +ASF installs the ruleset from the default branch. Forks need the equivalent +ruleset enabled in repository settings. Replace any old `PR merge gate` +requirement when deploying this version, and ensure no other rule requires +Jenkins. Live merge enforcement requires the ruleset to be installed on +GitHub. diff --git a/.github/workflows/pr-single-commit.yml b/.github/workflows/pr-single-commit.yml new file mode 100644 index 000000000000..2c7eae55d2ab --- /dev/null +++ b/.github/workflows/pr-single-commit.yml @@ -0,0 +1,102 @@ +# 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. + +name: PR Single Commit + +on: + pull_request: + types: [opened, reopened, synchronize, labeled, unlabeled, ready_for_review] + +permissions: + pull-requests: read + +concurrency: + group: pr-single-commit-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + check-single-commit: + name: Check single commit + if: contains(github.event.pull_request.labels.*.name, 'Approved') + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Verify approval and then count commits + uses: actions/github-script@v8 + with: + retries: 3 + script: | + const guide = 'https://calcite.apache.org/develop/#contributing'; + const fail = message => core.setFailed(`${message} See ${guide}.`); + try { + const pullRequest = { + ...context.repo, + pull_number: context.issue.number, + }; + const { data: pr } = await github.rest.pulls.get(pullRequest); + if (pr.state !== 'open') { + core.info('PR is closed; nothing to check.'); + return; + } + if (pr.head.sha !== context.payload.pull_request.head.sha) { + fail('PR commits changed. Use the check on the latest PR head.'); + return; + } + if (!pr.labels.some(label => label.name === 'Approved')) { + core.info('The Approved label was removed; commit count not checked.'); + return; + } + + // On synchronize, the sender is the contributor, not the label author. + // Look up who applied the current label instead of trusting the push actor. + const events = await github.paginate(github.rest.issues.listEvents, { + ...context.repo, + issue_number: pr.number, + per_page: 100, + }); + const approval = events + .filter(event => event.event === 'labeled' && event.label?.name === 'Approved') + .sort((a, b) => b.id - a.id)[0]; + if (!approval?.actor) { + fail('Cannot verify who applied Approved. A maintainer must remove and reapply it.'); + return; + } + // Includes write access inherited from the Calcite committer/PMC teams. + // MEMBER or COLLABORATOR alone is not proof of repository write permission. + const { data: access } = await github.rest.repos.getCollaboratorPermissionLevel({ + ...context.repo, + username: approval.actor.login, + }); + if (!['write', 'maintain', 'admin'].includes(access.permission)) { + fail(`The Approved label was added by ${approval.actor.login}, who lacks write access. ` + + 'A committer or PMC member with repository write access must remove and reapply it.'); + return; + } + + const { data: latest } = await github.rest.pulls.get(pullRequest); + if (latest.head.sha !== pr.head.sha || latest.base.sha !== pr.base.sha || + !latest.labels.some(label => label.name === 'Approved')) { + fail('PR commits, base, or approval changed during validation. Rerun this check.'); + return; + } + if (latest.commits !== 1) { + fail(`PR must contain exactly one commit after approval; found ${latest.commits}. ` + + 'Squash the commits and push again.'); + return; + } + core.info(`Approval verified for ${approval.actor.login}; PR contains exactly one commit.`); + } catch (error) { + fail(`Cannot verify approval and commit count: ${error.message}. Rerun this check.`); + } diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 000000000000..7df470d3dfad --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,149 @@ +# 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. + +name: PR Title + +on: + pull_request: + types: [opened, edited, synchronize, reopened, ready_for_review] + +permissions: + contents: read + pull-requests: read + +concurrency: + group: pr-title-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + check-pr-title: + name: Check PR title + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + # Use the PR workflow so contributors can exercise this check before it is merged. + # Read metadata only; do not check out or execute PR code. + - name: Compare PR title, Jira summary, and commit subjects + uses: actions/github-script@v8 + with: + retries: 3 + script: | + const guide = 'https://calcite.apache.org/develop/#contributing'; + const fail = message => core.setFailed(`${message} See ${guide}.`); + try { + const pullRequest = { + ...context.repo, + pull_number: context.issue.number, + }; + const { data: pr } = await github.rest.pulls.get(pullRequest); + if (pr.state !== 'open') { + core.info('PR is closed; nothing to check.'); + return; + } + if (pr.head.sha !== context.payload.pull_request.head.sha) { + fail('PR commits changed. Use the check on the latest PR head.'); + return; + } + const match = /^\[(CALCITE-[0-9]+)\]/.exec(pr.title); + if (!match) { + core.info('Title consistency check skipped: no [CALCITE-] prefix.'); + return; + } + + const key = match[1]; + let summary; + for (let attempt = 1; attempt <= 3; attempt++) { + try { + // Jira is public; never send the GitHub token to it. + const response = await fetch( + `https://issues.apache.org/jira/rest/api/2/issue/${key}?fields=summary`, + { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(15000), + } + ); + if (response.status === 404) { + fail(`Jira issue ${key} does not exist or is not public.`); + return; + } + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const issue = await response.json(); + summary = issue.fields?.summary; + if (typeof summary !== 'string' || !summary.trim()) { + throw new Error('Jira returned an empty summary'); + } + break; + } catch (error) { + if (attempt === 3) { + fail(`Cannot read Jira issue ${key}: ${error.message}. Rerun this check.`); + return; + } + core.warning(`Jira request failed; retrying (${attempt}/3).`); + await new Promise(resolve => setTimeout(resolve, attempt * 1000)); + } + } + + const expected = `[${key}] ${summary}`; + if (pr.title !== expected) { + fail(`PR title differs from Jira. Expected ${JSON.stringify(expected)}; ` + + `found ${JSON.stringify(pr.title)}.`); + return; + } + + // GraphQL pagination also covers PRs beyond the REST API's 250-commit limit. + let cursor = null; + let matchingCommit = false; + do { + const { repository } = await github.graphql(` + query($owner: String!, $repo: String!, $number: Int!, $cursor: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + headRefOid + commits(first: 100, after: $cursor) { + nodes { commit { message } } + pageInfo { hasNextPage endCursor } + } + } + } + }`, { ...context.repo, number: pr.number, cursor }); + const current = repository.pullRequest; + if (current.headRefOid !== pr.head.sha) { + fail('PR commits changed during validation. Use the latest PR check.'); + return; + } + matchingCommit = current.commits.nodes.some( + ({ commit }) => commit.message.split(/\r?\n/, 1)[0] === pr.title + ); + cursor = current.commits.pageInfo.hasNextPage + ? current.commits.pageInfo.endCursor : null; + } while (!matchingCommit && cursor); + + const { data: latest } = await github.rest.pulls.get(pullRequest); + if (latest.head.sha !== pr.head.sha || latest.title !== pr.title || + latest.base.sha !== pr.base.sha) { + fail('PR title, base, or commits changed during validation. Rerun this check.'); + return; + } + if (!matchingCommit) { + fail(`No commit message has the required first line ${JSON.stringify(pr.title)}. ` + + 'Update at least one commit subject; the message body may differ.'); + return; + } + core.info('PR title, Jira summary, and at least one commit subject match exactly.'); + } catch (error) { + fail(`Cannot validate title consistency: ${error.message}. Rerun this check.`); + } From 38d5d890e01efd4414fd22b6e4a239c8b5407174 Mon Sep 17 00:00:00 2001 From: xiedeyantu Date: Sun, 25 Oct 2026 23:13:31 +0800 Subject: [PATCH 2/3] Change label Signed-off-by: xiedeyantu --- .github/PR_CHECKS.md | 2 +- .github/workflows/pr-single-commit.yml | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/PR_CHECKS.md b/.github/PR_CHECKS.md index 67ff629e0cc1..7a5122791282 100644 --- a/.github/PR_CHECKS.md +++ b/.github/PR_CHECKS.md @@ -26,7 +26,7 @@ into `main`, with no bypass actors. GitHub enforces these checks directly. - `Check PR title` requires `[CALCITE-] ` to match the complete PR title and at least one commit subject exactly. Other prefixes skip this check. Title edits and pushes rerun it; Jira-only edits need a rerun. -- `Check single commit` runs only with the `Approved` label. It verifies that +- `Check single commit` runs only with the `LGTM-will-merge-soon` label. It verifies that the label was applied by someone with write, maintain, or admin access, then requires exactly one commit. GitHub Approve reviews are separate. diff --git a/.github/workflows/pr-single-commit.yml b/.github/workflows/pr-single-commit.yml index 2c7eae55d2ab..1b6cfd72a35e 100644 --- a/.github/workflows/pr-single-commit.yml +++ b/.github/workflows/pr-single-commit.yml @@ -29,7 +29,7 @@ concurrency: jobs: check-single-commit: name: Check single commit - if: contains(github.event.pull_request.labels.*.name, 'Approved') + if: contains(github.event.pull_request.labels.*.name, 'LGTM-will-merge-soon') runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -54,8 +54,8 @@ jobs: fail('PR commits changed. Use the check on the latest PR head.'); return; } - if (!pr.labels.some(label => label.name === 'Approved')) { - core.info('The Approved label was removed; commit count not checked.'); + if (!pr.labels.some(label => label.name === 'LGTM-will-merge-soon')) { + core.info('The LGTM-will-merge-soon label was removed; commit count not checked.'); return; } @@ -67,10 +67,10 @@ jobs: per_page: 100, }); const approval = events - .filter(event => event.event === 'labeled' && event.label?.name === 'Approved') + .filter(event => event.event === 'labeled' && event.label?.name === 'LGTM-will-merge-soon') .sort((a, b) => b.id - a.id)[0]; if (!approval?.actor) { - fail('Cannot verify who applied Approved. A maintainer must remove and reapply it.'); + fail('Cannot verify who applied LGTM-will-merge-soon. A maintainer must remove and reapply it.'); return; } // Includes write access inherited from the Calcite committer/PMC teams. @@ -80,14 +80,14 @@ jobs: username: approval.actor.login, }); if (!['write', 'maintain', 'admin'].includes(access.permission)) { - fail(`The Approved label was added by ${approval.actor.login}, who lacks write access. ` + + fail(`The LGTM-will-merge-soon label was added by ${approval.actor.login}, who lacks write access. ` + 'A committer or PMC member with repository write access must remove and reapply it.'); return; } const { data: latest } = await github.rest.pulls.get(pullRequest); if (latest.head.sha !== pr.head.sha || latest.base.sha !== pr.base.sha || - !latest.labels.some(label => label.name === 'Approved')) { + !latest.labels.some(label => label.name === 'LGTM-will-merge-soon')) { fail('PR commits, base, or approval changed during validation. Rerun this check.'); return; } From caebaa74e55c511656b996ab33294b1603107fcf Mon Sep 17 00:00:00 2001 From: xiedeyantu Date: Sun, 25 Oct 2026 23:19:32 +0800 Subject: [PATCH 3/3] Delete md Signed-off-by: xiedeyantu --- .github/PR_CHECKS.md | 45 -------------------------------------------- 1 file changed, 45 deletions(-) delete mode 100644 .github/PR_CHECKS.md diff --git a/.github/PR_CHECKS.md b/.github/PR_CHECKS.md deleted file mode 100644 index 7a5122791282..000000000000 --- a/.github/PR_CHECKS.md +++ /dev/null @@ -1,45 +0,0 @@ - - -# PR checks - -The [ruleset in `.asf.yaml`](../.asf.yaml) requires the 15 CI jobs in -`main.yml`, Gradle wrapper `Validation`, `SonarCloud` (code scanning), -`SonarCloud Code Analysis`, and the two PR metadata checks below to merge -into `main`, with no bypass actors. GitHub enforces these checks directly. -`continuous-integration/jenkins/pr-head` is not required by this ruleset. - -- `Check PR title` requires `[CALCITE-] ` to match the - complete PR title and at least one commit subject exactly. Other prefixes - skip this check. Title edits and pushes rerun it; Jira-only edits need a rerun. -- `Check single commit` runs only with the `LGTM-will-merge-soon` label. It verifies that - the label was applied by someone with write, maintain, or admin access, - then requires exactly one commit. GitHub Approve reviews are separate. - -Existing CI triggers and jobs are unchanged. If a required workflow does -not run (for example, `main.yml` ignores PRs changing only `site/**`), its -missing checks block merging. GitHub accepts success, skipped, and neutral -check conclusions; pending, failed, or cancelled checks block merging. -Slow tests and website builds are not added to the required list. - -When adding or renaming a required check, update its context in `.asf.yaml`. - -ASF installs the ruleset from the default branch. Forks need the equivalent -ruleset enabled in repository settings. Replace any old `PR merge gate` -requirement when deploying this version, and ensure no other rule requires -Jenkins. Live merge enforcement requires the ruleset to be installed on -GitHub.