From 74ef77e7e798f74f18274f92d6c53a9534bae42e Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Tue, 27 Oct 2026 11:53:10 -0400 Subject: [PATCH 1/3] Cancel unneeded pull request runs Merging a pull request, or labelling it "no ci", leaves its queued and in-progress runs consuming shared runner time, although their result can no longer change anything. Cancel the pull_request runs of every open pull request labelled "no ci", and on every push to a release branch also the runs created before the merge of each pull request the pushed commit merged. A run is kept while an open pull request not labelled "no ci" has the same head commit. The workflow runs on every push to a release branch and when a pull request is labelled "no ci". A pull_request run from a fork gets a read-only token, so labelling a fork's pull request skips the job and its runs are cancelled by the next push to a release branch; for the same reason merges are found from the push. The unfinished pull_request runs are listed once per sweep rather than per pull request, to spare the GITHUB_TOKEN rate limit, and pending runs waiting for a concurrency group are cancelled too. --- .github/workflows/cancel-unneeded-pr-runs.yml | 80 +++++++++++++++++++ AGENTS.md | 5 +- CONTRIBUTING.md | 3 + 3 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/cancel-unneeded-pr-runs.yml diff --git a/.github/workflows/cancel-unneeded-pr-runs.yml b/.github/workflows/cancel-unneeded-pr-runs.yml new file mode 100644 index 00000000000..9cbd03f1afb --- /dev/null +++ b/.github/workflows/cancel-unneeded-pr-runs.yml @@ -0,0 +1,80 @@ +# 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 +# +# https://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. + +# Merging a pull request, or labelling it "no ci", does not stop the runs still queued or in progress for its head +# commit, and their result can no longer change anything. This workflow cancels the pull_request runs of every +# open pull request labelled "no ci", and on a push to a release branch also those of every pull request merged by +# the pushed commit that were created before the merge. A run is kept when an open pull request not labelled +# "no ci" has the same head commit, since it reports for that pull request too. A pull_request run from a fork gets +# a read-only token, so the label only cancels at once on a pull request from this repository; the runs of a fork's +# pull request are cancelled by the next push to a release branch. That is also why merges are found from the push +# and not from the pull request being closed. +name: "Cancel Unneeded PR Runs" +on: + push: + branches: + - '[0-9]+.[0-9]+.x' + pull_request: + types: [ labeled ] +permissions: + actions: write + contents: read + pull-requests: read +jobs: + cancel: + name: "Cancel Unneeded PR Runs" + # Unlike every other workflow that reacts to a pull request, the "no ci" label runs this one rather than skipping it. + if: >- + github.event_name == 'push' + || (github.event.label.name == 'no ci' && github.event.pull_request.head.repo.full_name == github.repository) + runs-on: ubuntu-slim + timeout-minutes: 5 + steps: + - name: "🛑 Cancel the runs of merged and \"no ci\" pull requests" + env: + EVENT: ${{ github.event_name }} + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + SHA: ${{ github.sha }} + run: | + # Every open pull request as its head repository, its head commit and whether it is labelled "no ci". + gh api --paginate "repos/$REPO/pulls?state=open&per_page=100" \ + --jq '.[] | [.head.repo.full_name, .head.sha, (any(.labels[]; .name == "no ci") | tostring)] | @tsv' > open.tsv + # The head repository and head commit of each pull request whose runs are unneeded, with the time its runs + # must have been created before: the merge, or now for a pull request labelled "no ci". + { + if [[ "$EVENT" == push ]]; then + gh api "repos/$REPO/commits/$SHA/pulls" \ + --jq '.[] | select(.merged_at != null) | [.head.repo.full_name, .head.sha, .merged_at] | @tsv' + fi + awk -F '\t' -v now="$(date -u +%Y-%m-%dT%H:%M:%SZ)" '$3 == "true" { print $1 "\t" $2 "\t" now }' open.tsv + } > unneeded.tsv + # The runs of this workflow are never cancelled: a sweep started by another label would stop this one. + workflow=${GITHUB_WORKFLOW_REF#"$REPO/"} + workflow=${workflow%@*} + # Every pull_request run not finished yet, listed once rather than per pull request to spare the API rate + # limit; pending is a run waiting for its concurrency group. + for status in queued pending in_progress; do + gh api --paginate "repos/$REPO/actions/runs?event=pull_request&status=$status&per_page=100" \ + --jq ".workflow_runs[] | select(.path != \"$workflow\") | [.id, .head_repository.full_name, .head_sha, .created_at] | @tsv" + done > runs.tsv + awk -F '\t' ' + FILENAME == "open.tsv" { if ($3 == "false") kept[$2] = 1; next } + FILENAME == "unneeded.tsv" { if (!($2 in kept) && $3 > before[$1 FS $2]) before[$1 FS $2] = $3; next } + ($2 FS $3) in before && $4 < before[$2 FS $3] { print $1 } + ' open.tsv unneeded.tsv runs.tsv | sort -u | while read -r id; do + # A run that finished after it was listed cannot be cancelled, which is the outcome wanted anyway. + gh run cancel "$id" -R "$REPO" || echo "::warning title=Run not cancelled::Could not cancel run $id" + done diff --git a/AGENTS.md b/AGENTS.md index 7ae022db959..cd87682cdc3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,7 +57,7 @@ export GRADLE_OPTS="-Xms2G -Xmx5G" 11. **The BOM must manage the latest version** - `validateDependencyVersions` enforces that the BOM (`dependencies.gradle`) manages a version `>=` every transitively-resolved version. When it fails, **bump the version in `dependencies.gradle`** so the BOM wins — never silence it with `allowedBomOverrides` or an exclusion unless there is an explicit, documented conflict or an agreed-upon workaround. See [Dependency Management](#dependency-management). 12. **GitHub Actions must use ASF-approved references** - Every third-party action SHA must appear in the ASF allowlist; `actions/*` use the full tag of their latest release and `apache/*` use version or branch references, never SHAs. See [GitHub Actions](#github-actions). 13. **Workflows cache Gradle through one policy** - Every `setup-gradle` step takes `cache-read-only` from `.github/actions/gradle-cache-policy`; never write `true` or `false` there, never cache `~/.gradle` with `actions/cache`, and never pass `cache-writer` outside the core build. See [Gradle caching](#gradle-caching). -14. **Workflows run tests through one policy** - Every workflow that runs tests calls `.github/workflows/ci-policy.yml` as its first job, `policy`, and gates its test jobs on `needs.policy.outputs.run-tests`; never test `github.event.head_commit.message` in a workflow. Every job of every workflow that reacts to a pull request is skipped by the `no ci` label, and on a draft pull request unless it carries the `run ci` label; the one exception is the Release Drafter (`release-notes.yml`), whose autolabeler must label drafts. See [CI policy](#ci-policy). +14. **Workflows run tests through one policy** - Every workflow that runs tests calls `.github/workflows/ci-policy.yml` as its first job, `policy`, and gates its test jobs on `needs.policy.outputs.run-tests`; never test `github.event.head_commit.message` in a workflow. Every job of every workflow that reacts to a pull request is skipped by the `no ci` label, and on a draft pull request unless it carries the `run ci` label; the exceptions are the Release Drafter (`release-notes.yml`), whose autolabeler must label drafts, and `cancel-unneeded-pr-runs.yml`, which the `no ci` label runs. See [CI policy](#ci-policy). ## Available Skills @@ -251,7 +251,8 @@ Rules: GitHub-hosted runner time is shared with every other Apache project, so the workflows run tests only where the result is worth the runner time: -- A draft pull request runs nothing unless it carries the `run ci` label, and a pull request labelled `no ci` runs nothing at all: every workflow that reacts to a pull request skips all of its jobs, except the Release Drafter (`release-notes.yml`), which keeps reacting to every pull request into a release branch, draft or not, because its autolabeler must label drafts; it runs no build. Marking a draft ready for review starts CI (the workflows list the `ready_for_review` trigger type, which is not a default). Labels and the draft flag are read when a run starts, so set them before pushing; adding `run ci` or removing `no ci` takes effect on the next push, or when the draft is marked ready, and adding `no ci` does not stop a run in flight. A push to a draft, or to a `no ci` pull request, still cancels the run in flight for that pull request, as every push to a pull request or a branch does through the concurrency groups of the test workflows; the run that replaces it does nothing. A new workflow with a `pull_request` trigger lists the same `types` as its siblings and gates its jobs the same way. +- A draft pull request runs nothing unless it carries the `run ci` label, and a pull request labelled `no ci` runs nothing at all: every workflow that reacts to a pull request skips all of its jobs, except the Release Drafter (`release-notes.yml`), which keeps reacting to every pull request into a release branch, draft or not, because its autolabeler must label drafts; it runs no build. The other exception is `cancel-unneeded-pr-runs.yml`, described below. Marking a draft ready for review starts CI (the workflows list the `ready_for_review` trigger type, which is not a default). Labels and the draft flag are read when a run starts, so set them before pushing; adding `run ci` or removing `no ci` takes effect on the next push, or when the draft is marked ready, while adding `no ci` cancels the runs in flight (see below). A push to a draft, or to a `no ci` pull request, still cancels the run in flight for that pull request, as every push to a pull request or a branch does through the concurrency groups of the test workflows; the run that replaces it does nothing. A new workflow with a `pull_request` trigger lists the same `types` as its siblings and gates its jobs the same way. +- Merging a pull request, or labelling it `no ci`, cancels its runs in flight: `cancel-unneeded-pr-runs.yml` cancels the queued, pending and in-progress `pull_request` runs of every open pull request labelled `no ci`, and on a push to a release branch also those of each pull request the pushed commit merged that were created before the merge. A run is kept while an open pull request not labelled `no ci` has the same head commit. The workflow runs on every push to a release branch and when a pull request is labelled `no ci`, but a `pull_request` run from a fork gets a read-only token that cannot cancel runs, so labelling a fork's pull request skips the job, and that pull request's runs are cancelled by the next push to a release branch. For the same reason merges are found from the push rather than from the pull request being closed. It lists the unfinished `pull_request` runs of the repository once per sweep, not once per pull request, to spare the `GITHUB_TOKEN` rate limit. - Every workflow that runs tests calls `.github/workflows/ci-policy.yml` as its first job, `policy`, and gates its test jobs (and the test steps of its build jobs) on `needs.policy.outputs.run-tests`. Never test `github.event.head_commit.message` in a workflow: a `pull_request` payload has no such field, so that test silently ran the tests on every pull request. The policy reads the head commit of the push, of the pull request, or of the dispatched branch, and matches the marker against the start of the message's first line. The read is retried with a growing pause; when it still fails, the tests run, as they did before the policy existed, and a warning annotation and the step summary say so, so an API outage costs runner time rather than a test result. - The tests run by default only on the default branch and the release branches above it, and there a commit message starting with `[skip tests]` skips them. Everywhere else, that is on a release branch below the default (7.0.x, 7.1.x and 7.2.x while 8.0.x is the default) and on a pull request whose base is a side branch rather than a release branch, the tests run only on request: when the commit message starts with `[run tests]`, when the pull request carries the `run ci` label, or when a manual run (`workflow_dispatch`) in `apache/grails-core` has its `run-tests` input ticked. Otherwise the run builds, and on a push publishes snapshots, without tests, exactly like a `[skip tests]` commit; a `[skip tests]` commit also wins over the label and the input. The label is the way to test a backport pull request without rewriting its head commit, and the input is how the commit a release is cut from gets tested (see RELEASE.md); both are read when the run starts. A marker anywhere but at the start of the first line is ignored. The rule follows the default branch: when a new one is promoted, the branches below it stop running tests by default without an edit. The older release branches are kept quiet on purpose: a backport lands as one push to the oldest branch and a merge-up to each branch above it, and its tests belong on the pull request, labelled `run ci`, not on every push after it. Side branches are unreviewed development work (see the review process in CONTRIBUTING.md); their tests run when the branch is proposed to a release branch. In a fork, where the runner time is the fork owner's and not the ASF's, a manual run runs the tests unless the commit message starts with `[skip tests]`. - The jobs that run no tests (dependency validation, the build-without-tests steps, code style, build health, and the snapshot publish) still run on every push to a release branch and on every non-draft pull request; RAT and CodeQL run on every push to a release branch and on every non-draft pull request into one, since their `pull_request` triggers list only release-branch bases. Only the `no ci` label, or a draft without `run ci`, stops them. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1584107595b..136af62c3d0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -282,6 +282,9 @@ project, so two controls limit what runs: pull request into a release branch; it runs no build.) Labels are read when a push arrives, so add `run ci` (or remove `no ci`) and then push, or mark the draft ready, to get a run. Pushing to a draft still cancels the run in flight for it, as every push to a pull request or a branch does. +* Adding `no ci` to a pull request cancels the runs it has in flight, and merging a pull request cancels the runs + started before the merge. A pull request from a fork gets a read-only token, so its runs are cancelled by the next + push to a release branch instead of at once when it is labelled. * The tests run by default only on the default branch and the release branches above it. Elsewhere, that is on an older release branch such as `7.0.x` while `8.0.x` is the default, and on a pull request whose base is a side branch rather than a release branch, a push or pull request builds without running the tests unless asked: a From 4a939b992e674e0abb6b86118290c9fc8ffd6b29 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Tue, 27 Oct 2026 15:38:44 -0400 Subject: [PATCH 2/3] Stop running the release drafter on pull requests Backports the 8.0.x change. The pull_request trigger was there so the autolabeler could label pull requests, but the pinned release-drafter 7 entrypoint has no labelling: v7 moved it into the separate release-drafter/release-drafter/autolabeler action, which is not on the ASF approved list. A pull request run therefore applied no labels, and from a fork it could not write the draft either, failing behind continue-on-error on the read-only token. The draft is built from merged pull requests, so a pull request run only rewrote the draft the last push had written. It also shared the release-drafter- concurrency group with the push runs, so it could cancel a push run still queued or in progress; when the pull request run then failed, or was cancelled by cancel-unneeded-pr-runs.yml, the draft stayed stale until the next push. Drafts are now written only by the pushes that can change them, and the job reads pull requests instead of writing them. AGENTS.md and CONTRIBUTING.md no longer list the Release Drafter as a workflow that the "no ci" label and the draft flag leave running. --- .github/workflows/release-notes.yml | 48 +++++++++++++++++------------ AGENTS.md | 4 +-- CONTRIBUTING.md | 7 ++--- 3 files changed, 33 insertions(+), 26 deletions(-) diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml index d1dfb55dea9..a4cd9b38b85 100644 --- a/.github/workflows/release-notes.yml +++ b/.github/workflows/release-notes.yml @@ -38,14 +38,19 @@ on: push: branches: - '[0-9]+.[0-9]+.x' - # Runs on PRs whose BASE branch is a release branch so the autolabeler can - # apply labels (bug/feature/docs/...) and the draft picks up new PRs as soon - # as they are opened. Feature-to-feature PRs (e.g. fix/foo -> feat/bar) - # are intentionally excluded - they cannot affect any release. - pull_request: - types: [opened, reopened, synchronize, labeled] - branches: - - '[0-9]+.[0-9]+.x' + # There is deliberately no `pull_request` trigger. It was here so the autolabeler could + # label pull requests, and the action pinned below cannot do that: release-drafter 7 split + # labelling into a second entrypoint, `release-drafter/release-drafter/autolabeler`, and the + # entrypoint used here contains no labelling at all. A pull request run therefore applied no + # labels, and on a pull request from a fork it could not write a draft either - the token is + # read only, so it failed with "Resource not accessible by integration" and was hidden by + # `continue-on-error` below. Runs that could only fail also collided with each other: every + # pull request against a release branch computed the same concurrency group, so one cancelled + # another's check twelve seconds in. + # + # Restoring labels means adding the autolabeler entrypoint, which is a separate action and is + # not on the ASF approved list - `approved_patterns.yml` carries only + # `release-drafter/release-drafter@*` - so it needs an INFRA request first. # Manual recovery: rerun against any branch (e.g. to recreate a draft after # one was accidentally deleted, or to seed an initial draft on a new branch). workflow_dispatch: @@ -66,9 +71,11 @@ on: # # `cancel-in-progress: true`: if multiple pushes land on the same branch in # quick succession, only the latest matters - the latest run sees every PR -# the older one would have seen, so cancelling pending runs is correct. +# the older one would have seen, so cancelling pending runs is correct. With no +# pull request trigger, a branch is the only subject there is, so nothing shares +# this group with anything unrelated. concurrency: - group: release-drafter-${{ github.event.pull_request.base.ref || github.ref_name }} + group: release-drafter-${{ github.ref_name }} cancel-in-progress: true jobs: @@ -77,8 +84,10 @@ jobs: permissions: # Required to create or update the draft GitHub Release contents: write - # Required for the autolabeler to add labels to PRs - pull-requests: write + # Read, not write: nothing here labels anything any more. The draft is built from merged + # pull requests - the action's find-recent-merged-pull-requests asks for them - and a + # permissions block is restrictive, so the scope has to be named even to read. + pull-requests: read runs-on: ubuntu-latest steps: # Seed an initial version for release branches that have no published @@ -100,7 +109,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -uo pipefail - BRANCH="${{ github.event.pull_request.base.ref || github.ref_name }}" + BRANCH="${{ github.ref_name }}" if [[ ! "$BRANCH" =~ ^([0-9]+)\.([0-9]+)\.x$ ]]; then echo "Branch $BRANCH is not a release branch; no version seed." echo "version=" >> "$GITHUB_OUTPUT" @@ -154,12 +163,11 @@ jobs: # silently produced no draft (e.g. rate-limit exhaustion). continue-on-error: true with: - # Explicit `commitish` is critical on `pull_request` events: without - # it, release-drafter would default to `refs/pull/N/merge` (a - # virtual ref) which the GitHub API rejects when creating a release, - # producing the "Validation Failed: target_commitish invalid" error - # historically seen on PRs (see INFRA-27602). - commitish: ${{ github.event.pull_request.base.ref || github.ref_name }} + # Explicit `commitish`, kept from when this also ran on pull requests: it + # defaulted to `refs/pull/N/merge`, a virtual ref the GitHub API rejects + # when creating a release ("Validation Failed: target_commitish invalid", + # see INFRA-27602). Naming the branch is right for a push in any case. + commitish: ${{ github.ref_name }} # Empty for branches that already have a release (release-drafter # treats an empty version as "no override" and resolves the next # version itself). On a release-less branch this is MAJOR.MINOR.0 so @@ -183,7 +191,7 @@ jobs: DRAFT_NAME: ${{ steps.drafter.outputs.name }} DRAFT_URL: ${{ steps.drafter.outputs.html_url }} DRAFT_OUTCOME: ${{ steps.drafter.outcome }} - BRANCH: ${{ github.event.pull_request.base.ref || github.ref_name }} + BRANCH: ${{ github.ref_name }} run: | set -euo pipefail { diff --git a/AGENTS.md b/AGENTS.md index cd87682cdc3..354db5b77e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,7 +57,7 @@ export GRADLE_OPTS="-Xms2G -Xmx5G" 11. **The BOM must manage the latest version** - `validateDependencyVersions` enforces that the BOM (`dependencies.gradle`) manages a version `>=` every transitively-resolved version. When it fails, **bump the version in `dependencies.gradle`** so the BOM wins — never silence it with `allowedBomOverrides` or an exclusion unless there is an explicit, documented conflict or an agreed-upon workaround. See [Dependency Management](#dependency-management). 12. **GitHub Actions must use ASF-approved references** - Every third-party action SHA must appear in the ASF allowlist; `actions/*` use the full tag of their latest release and `apache/*` use version or branch references, never SHAs. See [GitHub Actions](#github-actions). 13. **Workflows cache Gradle through one policy** - Every `setup-gradle` step takes `cache-read-only` from `.github/actions/gradle-cache-policy`; never write `true` or `false` there, never cache `~/.gradle` with `actions/cache`, and never pass `cache-writer` outside the core build. See [Gradle caching](#gradle-caching). -14. **Workflows run tests through one policy** - Every workflow that runs tests calls `.github/workflows/ci-policy.yml` as its first job, `policy`, and gates its test jobs on `needs.policy.outputs.run-tests`; never test `github.event.head_commit.message` in a workflow. Every job of every workflow that reacts to a pull request is skipped by the `no ci` label, and on a draft pull request unless it carries the `run ci` label; the exceptions are the Release Drafter (`release-notes.yml`), whose autolabeler must label drafts, and `cancel-unneeded-pr-runs.yml`, which the `no ci` label runs. See [CI policy](#ci-policy). +14. **Workflows run tests through one policy** - Every workflow that runs tests calls `.github/workflows/ci-policy.yml` as its first job, `policy`, and gates its test jobs on `needs.policy.outputs.run-tests`; never test `github.event.head_commit.message` in a workflow. Every job of every workflow that reacts to a pull request is skipped by the `no ci` label, and on a draft pull request unless it carries the `run ci` label; the one exception is `cancel-unneeded-pr-runs.yml`, which the `no ci` label runs. See [CI policy](#ci-policy). ## Available Skills @@ -251,7 +251,7 @@ Rules: GitHub-hosted runner time is shared with every other Apache project, so the workflows run tests only where the result is worth the runner time: -- A draft pull request runs nothing unless it carries the `run ci` label, and a pull request labelled `no ci` runs nothing at all: every workflow that reacts to a pull request skips all of its jobs, except the Release Drafter (`release-notes.yml`), which keeps reacting to every pull request into a release branch, draft or not, because its autolabeler must label drafts; it runs no build. The other exception is `cancel-unneeded-pr-runs.yml`, described below. Marking a draft ready for review starts CI (the workflows list the `ready_for_review` trigger type, which is not a default). Labels and the draft flag are read when a run starts, so set them before pushing; adding `run ci` or removing `no ci` takes effect on the next push, or when the draft is marked ready, while adding `no ci` cancels the runs in flight (see below). A push to a draft, or to a `no ci` pull request, still cancels the run in flight for that pull request, as every push to a pull request or a branch does through the concurrency groups of the test workflows; the run that replaces it does nothing. A new workflow with a `pull_request` trigger lists the same `types` as its siblings and gates its jobs the same way. +- A draft pull request runs nothing unless it carries the `run ci` label, and a pull request labelled `no ci` runs nothing at all: every workflow that reacts to a pull request skips all of its jobs, except `cancel-unneeded-pr-runs.yml`, described below. The Release Drafter (`release-notes.yml`) does not react to pull requests: the action it pins cannot label them, and it builds the draft from merged pull requests, so it runs on pushes to a release branch. Marking a draft ready for review starts CI (the workflows list the `ready_for_review` trigger type, which is not a default). Labels and the draft flag are read when a run starts, so set them before pushing; adding `run ci` or removing `no ci` takes effect on the next push, or when the draft is marked ready, while adding `no ci` cancels the runs in flight (see below). A push to a draft, or to a `no ci` pull request, still cancels the run in flight for that pull request, as every push to a pull request or a branch does through the concurrency groups of the test workflows; the run that replaces it does nothing. A new workflow with a `pull_request` trigger lists the same `types` as its siblings and gates its jobs the same way. - Merging a pull request, or labelling it `no ci`, cancels its runs in flight: `cancel-unneeded-pr-runs.yml` cancels the queued, pending and in-progress `pull_request` runs of every open pull request labelled `no ci`, and on a push to a release branch also those of each pull request the pushed commit merged that were created before the merge. A run is kept while an open pull request not labelled `no ci` has the same head commit. The workflow runs on every push to a release branch and when a pull request is labelled `no ci`, but a `pull_request` run from a fork gets a read-only token that cannot cancel runs, so labelling a fork's pull request skips the job, and that pull request's runs are cancelled by the next push to a release branch. For the same reason merges are found from the push rather than from the pull request being closed. It lists the unfinished `pull_request` runs of the repository once per sweep, not once per pull request, to spare the `GITHUB_TOKEN` rate limit. - Every workflow that runs tests calls `.github/workflows/ci-policy.yml` as its first job, `policy`, and gates its test jobs (and the test steps of its build jobs) on `needs.policy.outputs.run-tests`. Never test `github.event.head_commit.message` in a workflow: a `pull_request` payload has no such field, so that test silently ran the tests on every pull request. The policy reads the head commit of the push, of the pull request, or of the dispatched branch, and matches the marker against the start of the message's first line. The read is retried with a growing pause; when it still fails, the tests run, as they did before the policy existed, and a warning annotation and the step summary say so, so an API outage costs runner time rather than a test result. - The tests run by default only on the default branch and the release branches above it, and there a commit message starting with `[skip tests]` skips them. Everywhere else, that is on a release branch below the default (7.0.x, 7.1.x and 7.2.x while 8.0.x is the default) and on a pull request whose base is a side branch rather than a release branch, the tests run only on request: when the commit message starts with `[run tests]`, when the pull request carries the `run ci` label, or when a manual run (`workflow_dispatch`) in `apache/grails-core` has its `run-tests` input ticked. Otherwise the run builds, and on a push publishes snapshots, without tests, exactly like a `[skip tests]` commit; a `[skip tests]` commit also wins over the label and the input. The label is the way to test a backport pull request without rewriting its head commit, and the input is how the commit a release is cut from gets tested (see RELEASE.md); both are read when the run starts. A marker anywhere but at the start of the first line is ignored. The rule follows the default branch: when a new one is promoted, the branches below it stop running tests by default without an edit. The older release branches are kept quiet on purpose: a backport lands as one push to the oldest branch and a merge-up to each branch above it, and its tests belong on the pull request, labelled `run ci`, not on every push after it. Side branches are unreviewed development work (see the review process in CONTRIBUTING.md); their tests run when the branch is proposed to a release branch. In a fork, where the runner time is the fork owner's and not the ASF's, a manual run runs the tests unless the commit message starts with `[skip tests]`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 136af62c3d0..6bde902a5c1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -278,10 +278,9 @@ Every push and pull request runs the GitHub Actions workflows, and runner time i project, so two controls limit what runs: * A draft pull request runs no workflow unless it carries the `run ci` label; marking it ready for review starts CI. - A pull request labelled `no ci` runs no workflow at all, draft or not. (The Release Drafter still labels every - pull request into a release branch; it runs no build.) Labels are read when a push arrives, so add `run ci` (or - remove `no ci`) and then push, or mark the draft ready, to get a run. Pushing to a draft still cancels the run in - flight for it, as every push to a pull request or a branch does. + A pull request labelled `no ci` runs no workflow at all, draft or not. Labels are read when a push arrives, so add + `run ci` (or remove `no ci`) and then push, or mark the draft ready, to get a run. Pushing to a draft still cancels + the run in flight for it, as every push to a pull request or a branch does. * Adding `no ci` to a pull request cancels the runs it has in flight, and merging a pull request cancels the runs started before the merge. A pull request from a fork gets a read-only token, so its runs are cancelled by the next push to a release branch instead of at once when it is labelled. From 45672af32d04d05f70a84cbe6e1963038644864a Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Wed, 28 Oct 2026 12:18:19 -0400 Subject: [PATCH 3/3] Keep the runs of merged pull requests labelled run ci On a release branch below the default branch the push after a merge builds without the tests, and a backport's tests belong on the pull request, labelled "run ci". There the pull request's runs are the only test result the change gets, so their result still matters after the merge: a late failure calls for a follow-up fix. The sweep no longer cancels the runs of a merged pull request labelled "run ci". CONTRIBUTING.md now says that merging cancels runs only on a merge into a release branch, since the push trigger covers only release branches. --- .github/workflows/cancel-unneeded-pr-runs.yml | 14 +++++++++----- AGENTS.md | 2 +- CONTRIBUTING.md | 8 +++++--- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/.github/workflows/cancel-unneeded-pr-runs.yml b/.github/workflows/cancel-unneeded-pr-runs.yml index 9cbd03f1afb..cebf381c626 100644 --- a/.github/workflows/cancel-unneeded-pr-runs.yml +++ b/.github/workflows/cancel-unneeded-pr-runs.yml @@ -14,9 +14,12 @@ # limitations under the License. # Merging a pull request, or labelling it "no ci", does not stop the runs still queued or in progress for its head -# commit, and their result can no longer change anything. This workflow cancels the pull_request runs of every -# open pull request labelled "no ci", and on a push to a release branch also those of every pull request merged by -# the pushed commit that were created before the merge. A run is kept when an open pull request not labelled +# commit, and except for the tests a "run ci" label asked for, their result can no longer change anything. This +# workflow cancels the pull_request runs of every open pull request labelled "no ci", and on a push to a release +# branch also those of every pull request merged by the pushed commit that were created before the merge, unless +# that pull request is labelled "run ci": on a release branch below the default branch, the push after a merge +# builds without the tests, so the tests the label ran on the pull request are the only ones the change gets there, +# and a late failure still calls for a follow-up fix. A run is also kept when an open pull request not labelled # "no ci" has the same head commit, since it reports for that pull request too. A pull_request run from a fork gets # a read-only token, so the label only cancels at once on a pull request from this repository; the runs of a fork's # pull request are cancelled by the next push to a release branch. That is also why merges are found from the push @@ -53,11 +56,12 @@ jobs: gh api --paginate "repos/$REPO/pulls?state=open&per_page=100" \ --jq '.[] | [.head.repo.full_name, .head.sha, (any(.labels[]; .name == "no ci") | tostring)] | @tsv' > open.tsv # The head repository and head commit of each pull request whose runs are unneeded, with the time its runs - # must have been created before: the merge, or now for a pull request labelled "no ci". + # must have been created before: the merge, or now for a pull request labelled "no ci". A merged pull + # request labelled "run ci" is left out, since its tests may be the only ones its change gets. { if [[ "$EVENT" == push ]]; then gh api "repos/$REPO/commits/$SHA/pulls" \ - --jq '.[] | select(.merged_at != null) | [.head.repo.full_name, .head.sha, .merged_at] | @tsv' + --jq '.[] | select(.merged_at != null and (any(.labels[]; .name == "run ci") | not)) | [.head.repo.full_name, .head.sha, .merged_at] | @tsv' fi awk -F '\t' -v now="$(date -u +%Y-%m-%dT%H:%M:%SZ)" '$3 == "true" { print $1 "\t" $2 "\t" now }' open.tsv } > unneeded.tsv diff --git a/AGENTS.md b/AGENTS.md index 354db5b77e9..cf9e3aba24c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -252,7 +252,7 @@ Rules: GitHub-hosted runner time is shared with every other Apache project, so the workflows run tests only where the result is worth the runner time: - A draft pull request runs nothing unless it carries the `run ci` label, and a pull request labelled `no ci` runs nothing at all: every workflow that reacts to a pull request skips all of its jobs, except `cancel-unneeded-pr-runs.yml`, described below. The Release Drafter (`release-notes.yml`) does not react to pull requests: the action it pins cannot label them, and it builds the draft from merged pull requests, so it runs on pushes to a release branch. Marking a draft ready for review starts CI (the workflows list the `ready_for_review` trigger type, which is not a default). Labels and the draft flag are read when a run starts, so set them before pushing; adding `run ci` or removing `no ci` takes effect on the next push, or when the draft is marked ready, while adding `no ci` cancels the runs in flight (see below). A push to a draft, or to a `no ci` pull request, still cancels the run in flight for that pull request, as every push to a pull request or a branch does through the concurrency groups of the test workflows; the run that replaces it does nothing. A new workflow with a `pull_request` trigger lists the same `types` as its siblings and gates its jobs the same way. -- Merging a pull request, or labelling it `no ci`, cancels its runs in flight: `cancel-unneeded-pr-runs.yml` cancels the queued, pending and in-progress `pull_request` runs of every open pull request labelled `no ci`, and on a push to a release branch also those of each pull request the pushed commit merged that were created before the merge. A run is kept while an open pull request not labelled `no ci` has the same head commit. The workflow runs on every push to a release branch and when a pull request is labelled `no ci`, but a `pull_request` run from a fork gets a read-only token that cannot cancel runs, so labelling a fork's pull request skips the job, and that pull request's runs are cancelled by the next push to a release branch. For the same reason merges are found from the push rather than from the pull request being closed. It lists the unfinished `pull_request` runs of the repository once per sweep, not once per pull request, to spare the `GITHUB_TOKEN` rate limit. +- Merging a pull request, or labelling it `no ci`, cancels its runs in flight: `cancel-unneeded-pr-runs.yml` cancels the queued, pending and in-progress `pull_request` runs of every open pull request labelled `no ci`, and on a push to a release branch also those of each pull request the pushed commit merged that were created before the merge. A run is kept while an open pull request not labelled `no ci` has the same head commit, and the runs of a merged pull request labelled `run ci` are kept too: on a release branch below the default, the push after the merge builds without the tests, so the tests the label asked for on the pull request are the only ones the change gets there, and a late failure still calls for a follow-up fix. The workflow runs on every push to a release branch and when a pull request is labelled `no ci`, but a `pull_request` run from a fork gets a read-only token that cannot cancel runs, so labelling a fork's pull request skips the job, and that pull request's runs are cancelled by the next push to a release branch. For the same reason merges are found from the push rather than from the pull request being closed. It lists the unfinished `pull_request` runs of the repository once per sweep, not once per pull request, to spare the `GITHUB_TOKEN` rate limit. - Every workflow that runs tests calls `.github/workflows/ci-policy.yml` as its first job, `policy`, and gates its test jobs (and the test steps of its build jobs) on `needs.policy.outputs.run-tests`. Never test `github.event.head_commit.message` in a workflow: a `pull_request` payload has no such field, so that test silently ran the tests on every pull request. The policy reads the head commit of the push, of the pull request, or of the dispatched branch, and matches the marker against the start of the message's first line. The read is retried with a growing pause; when it still fails, the tests run, as they did before the policy existed, and a warning annotation and the step summary say so, so an API outage costs runner time rather than a test result. - The tests run by default only on the default branch and the release branches above it, and there a commit message starting with `[skip tests]` skips them. Everywhere else, that is on a release branch below the default (7.0.x, 7.1.x and 7.2.x while 8.0.x is the default) and on a pull request whose base is a side branch rather than a release branch, the tests run only on request: when the commit message starts with `[run tests]`, when the pull request carries the `run ci` label, or when a manual run (`workflow_dispatch`) in `apache/grails-core` has its `run-tests` input ticked. Otherwise the run builds, and on a push publishes snapshots, without tests, exactly like a `[skip tests]` commit; a `[skip tests]` commit also wins over the label and the input. The label is the way to test a backport pull request without rewriting its head commit, and the input is how the commit a release is cut from gets tested (see RELEASE.md); both are read when the run starts. A marker anywhere but at the start of the first line is ignored. The rule follows the default branch: when a new one is promoted, the branches below it stop running tests by default without an edit. The older release branches are kept quiet on purpose: a backport lands as one push to the oldest branch and a merge-up to each branch above it, and its tests belong on the pull request, labelled `run ci`, not on every push after it. Side branches are unreviewed development work (see the review process in CONTRIBUTING.md); their tests run when the branch is proposed to a release branch. In a fork, where the runner time is the fork owner's and not the ASF's, a manual run runs the tests unless the commit message starts with `[skip tests]`. - The jobs that run no tests (dependency validation, the build-without-tests steps, code style, build health, and the snapshot publish) still run on every push to a release branch and on every non-draft pull request; RAT and CodeQL run on every push to a release branch and on every non-draft pull request into one, since their `pull_request` triggers list only release-branch bases. Only the `no ci` label, or a draft without `run ci`, stops them. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6bde902a5c1..961e5047dbd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -281,9 +281,11 @@ project, so two controls limit what runs: A pull request labelled `no ci` runs no workflow at all, draft or not. Labels are read when a push arrives, so add `run ci` (or remove `no ci`) and then push, or mark the draft ready, to get a run. Pushing to a draft still cancels the run in flight for it, as every push to a pull request or a branch does. -* Adding `no ci` to a pull request cancels the runs it has in flight, and merging a pull request cancels the runs - started before the merge. A pull request from a fork gets a read-only token, so its runs are cancelled by the next - push to a release branch instead of at once when it is labelled. +* Adding `no ci` to a pull request cancels the runs it has in flight, and merging a pull request into a release branch + cancels the runs started before the merge, unless the pull request is labelled `run ci`: on an older release branch + the push after the merge does not run the tests, so the tests the label asked for are left to finish. A pull request + from a fork gets a read-only token, so its runs are cancelled by the next push to a release branch instead of at + once when it is labelled. * The tests run by default only on the default branch and the release branches above it. Elsewhere, that is on an older release branch such as `7.0.x` while `8.0.x` is the default, and on a pull request whose base is a side branch rather than a release branch, a push or pull request builds without running the tests unless asked: a