Skip to content

ci: migrate pull_request_target workflows to workflow_run - #26683

Open
comphead wants to merge 13 commits into
apache:mainfrom
comphead:ci-pr-target-workflow-run
Open

comphead wants to merge 13 commits into
apache:mainfrom
comphead:ci-pr-target-workflow-run

Conversation

@comphead

@comphead comphead commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

Three workflows use pull_request_target: labeler.yml, fork_ci_status.yml and release_version_labeler.yaml. ASF infra disables the trigger from November 2026. GitHub also adds a default policy that blocks it on public repositories, enforced from 2026-12-02. Without this change, PRs stop getting labels, the Rust (fork) check and the release version label.

What changes are included in this PR?

  • Add pull_request_signal.yml, an unprivileged pull_request workflow with permissions: {} and one echo step. Its header lists what it does not cover: a PR that edits its own copy of the file, a PR with merge conflicts, a head commit with a skip marker, and re-runs, which do not send requested.
  • labeler.yml and fork_ci_status.yml run on workflow_run of that workflow with types: [requested]. GitHub runs them from the default branch, so they never run PR code. requested is sent at once, even for a first-time contributor whose run waits for approval.
  • workflow_run.pull_requests is empty for fork PRs. Both workflows find the PRs with the shared ci/scripts/pull_requests_of_run.js, after a sparse checkout of ci/scripts from the default branch (never the PR). The labeler also checks out its config, so actions/labeler reads it from the file. It lists the open PRs of the run's head branch and returns none when the fork was deleted. labeler.yml labels every match, since the same branch can target main and a release branch, and passes the numbers to actions/labeler as pr-number. The matches are the current PRs, so a delayed run for an older commit still labels the latest head. fork_ci_status.yml looks only at PRs to main and skips a commit that is no longer the PR head, because its scheduled update creates the check of the new head. When the commit already has a Rust (fork) check, for example after a signal from a PR of the same branch to a release branch or from a reopened PR, it updates that check instead of adding a second one. It looks for that check only after waiting for the fork run, so a check that the scheduled update created meanwhile is updated too. Its job also skips workflow_run events that are not from a fork before a runner starts.
  • Concurrency groups use the workflow_run head repository and branch, since github.head_ref and the PR number are not set. In fork_ci_status.yml only workflow_run events use that group, so a run from a deleted fork cannot cancel the scheduled sweep.
  • release_version_labeler.yaml runs on push to main in apache/datafusion only, so a fork that syncs its main does nothing. Each run labels every PR merged to main in the last 7 days that has no release label yet, with the version that release_version_labeler.js names at the PR's merge commit. A later push therefore labels a merge whose own push did not run the workflow, for example because of a skip marker in the squash commit message, which comes from the PR body, or whose run failed. A failed read or label is logged with its PR, the other PRs are labeled, and the run then fails. A 422 from createLabel counts as success, since another run can create the label first. The checkout is sparse and does not persist credentials, actions/github-script is pinned by commit, and the concurrency group is removed, since it never applied to a push.
  • test_fork_ci_status.cjs now drives the notification with a workflow_run payload and covers the shared lookup. The new test_release_version_labeler.cjs covers the release labeler. Both run in dev.yml and dev/rust_lint.sh, and testing_infrastructure.md lists the new one with the other CI workflow tests, including the merge queue cleanup tests it did not list yet.

What is the testing strategy for this PR?

  • node --test ci/scripts/test_fork_ci_status.cjs ci/scripts/test_release_version_labeler.cjs passes (45 tests). They include a newer release-branch PR next to an older main PR from the same branch, a PR whose head moved after the run, a deleted fork, a stale commit, a commit that already has the check or gets it while the run waits, the concurrency groups and the job if. For the release labeler they cover the version at an earlier merge commit, the window and its paging, existing release labels, a missing or concurrently created label, a failure that is logged and still labels the other PRs, and a check that dev/update_datafusion_versions.py rewrites only the version constant. Each fix was also checked by breaking it and seeing its test fail.
  • actionlint, hawkeye and typos report nothing on a merge with main.
  • The lookups were replayed read-only against the 392 open PRs and the 40 most recent merges to main. Each resolved to the right PR.
  • Dependabot PRs keep their labels: the datafusion-ballista labeler, migrated the same way, labeled Dependabot PRs Eliminate multi limit-offset nodes to emptyRelation  #2823 and Add Python examples for reading CSV and query by SQL in Doc #2824 from workflow_run runs triggered by Dependabot on 2026-11-09. The push of a merged Dependabot PR to main comes from github-merge-queue[bot], so the release version labeler gets a normal token.
  • The workflow_run flow only takes effect after the merge to main, because GitHub runs these workflows from the default branch. It is not tested end to end. Things to check after the merge: that a PR from a fork gets labels and the Rust (fork) check, and that merge queue commits get the release label. actions/labeler reads labeler-config.yml at github.context.sha, which is the default branch under workflow_run (read from the action source, not run).

Are there any user-facing changes?

No. A PR with merge conflicts, or whose head commit has a skip marker, does not start pull_request workflows, so it is labeled with a later push, and the scheduled update creates its Rust (fork) check. The edited label trigger was dropped, since the old workflow skipped that action already.

pull_request_target is disabled by ASF infra policy and GitHub is adding a
default policy that blocks it. Add pull_request_signal.yml and trigger
labeler.yml and fork_ci_status.yml from its workflow_run. Label merged pull
requests from the push to main in release_version_labeler.yaml.
@github-actions github-actions Bot added the development-process Related to development process of DataFusion label Oct 23, 2026
@alamb

alamb commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Thank you for working on this

@comphead

comphead commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor Author

Thank you for working on this

All the credits to Apache Spark community, lets see if it works for us

@potiuk

potiuk commented Oct 23, 2026

Copy link
Copy Markdown
Member

Nice! Good job :) . World is better with every single pull_request_target removed!

@potiuk

potiuk commented Oct 23, 2026

Copy link
Copy Markdown
Member

Glad my examples were helpful and useful to replicate :D

Comment thread .github/workflows/labeler.yml Resolved
Label every pull request that matches the head commit, and mirror the fork
check only for the pull request to main.
The merged pull request can lag the push to main by a few seconds. Retry up
to three times, and fail the job when no merged pull request is found.
@comphead
comphead marked this pull request as ready for review November 4, 2026 02:10
@comphead
comphead requested a review from kumarUjjawal November 4, 2026 02:10
Comment thread ci/scripts/release_version_labeler.js Outdated
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.sha
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On push, the script looks up PRs only for context.sha, so every other PR merged by the same push never gets the release version label.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4c3c2e9. The script now looks up every commit in context.payload.commits (or context.sha when the payload has none) and labels each merged PR once. In the last 40 merges every merge commit was the head of its own push, so this is robustness and not a current gap. test_release_version_labeler.cjs covers a push with two commits that share a PR.

Comment thread ci/scripts/release_version_labeler.js Outdated
});
pulls = data.filter((pull) => pull.merged_at && pull.base.ref === 'main');
}
if (pulls.length === 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The script now throws when a push to main has no merged PR, which turns ordinary direct pushes into failed workflow runs on main.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. After the three attempts it now logs No merged pull request found for <sha> and the run succeeds. All of the last 100 commits on main had a merged PR, but a failed run on main is too noisy for a cosmetic label. A test covers it.

# - labeler.yml
# - fork_ci_status.yml
name: Pull request signal

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The signal workflow runs from the PR's own version of the file, so a PR that renames, edits or deletes it turns off labeling and the Rust (fork) check for itself without any error.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, a PR runs its own copy of the file. It can only skip the labels and the immediate Rust (fork) check for itself, and it gains nothing else. The signal run has permissions: {}, and the privileged workflows only read the head repository, branch and SHA of the run, which the PR author controls anyway. The scheduled update in fork_ci_status.yml still creates a missing Rust (fork) check, and the merge queue stays the gate. I documented this in the header of the signal workflow in 4c3c2e9.

Comment thread .github/workflows/fork_ci_status.yml Outdated

async function notify() {
const pr = context.payload.pull_request;
// An outdated commit matches nothing, since a newer run mirrors the new head.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The script dereferences run.head_repository.owner.login without a null check; head_repository is null when the fork was deleted or is unavailable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4c3c2e9. The shared lookup returns no pull requests when head_repository is null, so neither workflow dereferences it. Tests cover the lookup and the fork_ci_status notification.

# find the pull requests by their head branch and commit. The same branch
# can target `main` and a release branch, so label every match. An
# outdated commit matches nothing, since a newer run labels the new head.
- name: Find the pull requests

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR lookup by head branch plus SHA is written twice: inline in labeler.yml and in pullRequestOf in fork_ci_status.yml. Only the second copy has tests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4c3c2e9. Both workflows now call ci/scripts/pull_requests_of_run.js, after a sparse checkout of ci/scripts from the default branch (never the pull request). The tests in test_fork_ci_status.cjs cover it directly, including the no-base case that the labeler uses.

# default policy that blocks it. The `workflow_run` workflows below run from the
# default branch with a write token and never check out or run pull request
# code. They trigger on the `requested` event of this workflow, which GitHub
# sends at once for every run, even for a first-time contributor whose run waits

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The design relies on GitHub sending workflow_run: requested for a run that is waiting for first-time-contributor approval. That is not documented; GitHub may hold the event until a maintainer approves the run.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right that it is not documented. It is observed behavior. In apache/spark-kubernetes-operator two signal runs from an outside fork stayed completed/action_required (never approved), and each still started its workflow_run consumer 2 to 3 seconds later. I noted this in the header of the signal workflow in 4c3c2e9. If GitHub changes it, the scheduled update in fork_ci_status.yml still creates a missing Rust (fork) check, but labeling would need a follow-up.

@@ -11,16 +11,15 @@ concurrency:
group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ github.workflow }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The concurrency group still uses github.head_ref || github.sha. With a push trigger head_ref is always empty and every SHA is unique, so the group and cancel-in-progress no longer do anything.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4c3c2e9. I removed the concurrency block. Each push needs its own run, so there is nothing to cancel or serialize.

Comment thread .github/workflows/fork_ci_status.yml Outdated
# `pull_request_signal.yml` it reads the fork's public run list and creates the
# check (`pull_request_target` is disabled by ASF infra policy), and on a
# schedule it updates the active or missing runs of recently updated pull
# requests plus a rotating slice of all open pull requests. The check is informational. The merge

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rewritten header comment is not re-wrapped; the line "requests plus a rotating slice ... The merge" runs far past the wrap width of the lines around it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4c3c2e9. Re-wrapped, and I dropped the parenthetical that the header of the signal workflow already explains.

@Jefffrey

Jefffrey commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

regarding labelling, have we considered using a bot? it was suggested on an infra confluence page and seems to be used by airflow too

There are some typical tasks done wit pull_request_target - for example labeling PRs with https://github.com/actions/labeler. Those could be often replaced with GitHub Apps - for example there is a "Boring Cyborg" GitHub App: https://github.com/kaxil/boring-cyborg that has similar functionality (among others). Also Boring Cyborg could be extended if some functionalities are missing. PRs are most welcome.

Airflow uses it:

one note is we'd need an infra ticket to enable the permissions, e.g.

- Share the pull request lookup of the labeler and fork_ci_status workflows in
  ci/scripts/pull_requests_of_run.js, guard against a deleted fork, and test it.
- Label every merged pull request in a push, and log instead of failing when a
  push has none.
- Drop the concurrency group of the release version labeler, which never
  applied to a push.
- Document the signal workflow limits and re-wrap the fork_ci_status header.
@comphead

comphead commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

This PR keeps the current actions/labeler setup and only changes the trigger, so labeling keeps working when pull_request_target is blocked. A bot like Boring Cyborg would need an INFRA ticket for the permissions, so it would be a separate change. If people prefer that route, I am happy to look at it.

Comment thread ci/scripts/test_fork_ci_status.cjs Outdated
runs: [run(1)],
});

test("notification skips a run whose fork was deleted", async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A misplaced closing brace leaves five new tests, plus the findPullRequests/lookup helpers, inside the body of the test "notification skips a commit that is no longer a pull request head"; that test's own assertion ends up at line 266, after them.

Failure scenario: The inner test() calls (deleted fork, three lookup tests) become unawaited subtests of the stale-head test instead of top-level tests. If the parent finishes or fails first, node:test cancels them or reports them as failures under the wrong name. Whether they run at all depends on the Node version's subtest handling. The stale-head check itself only runs after these extra subtests have been registered.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. The }); I matched was the end of the execute(...) call, so the new tests ran as subtests of the stale-head test. Fixed in f64546b. They are top-level tests now (the runner output has no indented test lines), and the stale-head test asserts on its own again.

pull_request_target:
types:
- closed
push:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new push: main trigger has no if: github.repository == 'apache/datafusion' guard, so it now runs in every fork that has Actions enabled. Forks were affected before only when PRs were closed inside the fork itself.

Failure scenario: The contributor guide asks fork owners to enable workflows. A contributor who syncs their fork's main then triggers this job on each sync. It creates a v57.0.0 label in the fork, waits through the retries, and calls addLabels on the fork for any PR numbers the lookup returns. That spends the fork's minutes and can label the wrong fork issue or fail with a 404. merge_queue_cleanup.yml and draft_inactive_prs.yml already use this guard.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f64546b. The job now has if: github.repository == 'apache/datafusion', like merge_queue_cleanup.yml and draft_inactive_prs.yml.

# and the merge queue stays the gate.
name: Pull request signal

on:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The signal uses pull_request, which GitHub does not run while a PR has merge conflicts (there is no merge ref). The old pull_request_target triggers ran either way.

Failure scenario: A contributor pushes to a PR that conflicts with main. No signal run starts, so labeler.yml never re-syncs labels for the new files and fork_ci_status.yml never creates the immediate Rust (fork) check. Labels stay stale until the conflict is fixed and another push lands, and the header comment does not mention this gap.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, this is a known gap. I noted it in the header of the signal workflow in f64546b. A PR with conflicts gets its labels after the push that resolves them. The scheduled update in fork_ci_status.yml creates the Rust (fork) check regardless, because it selects PRs by their head and base and not by mergeability. The Rust workflow has the same limit, since it also runs on pull_request.

Comment thread .github/workflows/fork_ci_status.yml Outdated
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || 'update' }}
cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name && format('{0}-{1}', github.event.workflow_run.head_repository.full_name, github.event.workflow_run.head_branch) || 'update' }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When workflow_run.head_repository is null (deleted fork), the concurrency expression falls back to the update group used by the scheduled sweep, while cancel-in-progress is still true because the event is workflow_run.

A signal run, or a rerun, from a PR whose fork was deleted triggers this workflow. It joins group Fork CI status-update and cancels the scheduled or manually dispatched sweep that is running, even though its own notify() finds nothing to do.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in f64546b. The per-branch group is now chosen by github.event_name == 'workflow_run', so a signal run from a deleted fork gets its own group and no longer cancels the sweep. Two tests cover the groups, and the one for the deleted fork fails with the old expression.

Comment thread ci/scripts/release_version_labeler.js Outdated
// A push can include several merged pull requests.
const shas = (context.payload.commits || []).map((commit) => commit.id);
const numbers = new Set();
for (const sha of shas.length ? shas : [context.sha]) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each commit in the push is looked up one after another, and every commit without a merged PR costs two 5-second waits.
A direct push to main with N commits that have no PR blocks the job for about 10s × N (20 commits is about 200s of runner time) before it gives up. Retrying only the head commit, or looking up commits in parallel, would avoid most of that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f64546b. The commits are looked up in parallel with Promise.all, each with its own retries, so a push whose commits have no PR waits about 10 seconds in total and not 10 seconds per commit. A test checks that the lookups overlap and that only the lagging commit is retried.

Comment thread ci/scripts/release_version_labeler.js Outdated
}
}
if (numbers.size === 0) {
console.log(`No merged pull request found for ${context.sha}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "No merged pull request found" message prints only context.sha, although the script searched every commit in payload.commits.

A multi-commit push where none of the commits maps to a merged PR logs only the head SHA, so whoever debugs a missing label can't tell which commits were checked.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f64546b. The message lists every commit that was searched. The old test had locked in the head SHA, so it now checks a push with two commits.

- Close the stale-head test before the new tests, which were nested in it.
- Skip the release version labeler outside apache/datafusion.
- Keep a signal run from a deleted fork out of the sweep concurrency group.
- Look up the commits of a push in parallel and name them all in the log.
- Note in the signal workflow that pull requests with conflicts are skipped.
Comment thread ci/scripts/release_version_labeler.js Outdated
// parallel, so the retries of commits without one overlap.
const pushed = (context.payload.commits || []).map((commit) => commit.id);
const shas = pushed.length ? pushed : [context.sha];
const found = await Promise.all(shas.map((sha) => mergedPulls({ github, context, sha })));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All commits of the push are looked up at the same time with no limit, and one failed lookup makes Promise.all reject, so no pull request in the push gets the label.

A push to main can hold many commits, for example a branch merged with a merge commit or a large merge queue group. Each commit starts its own listPullRequestsAssociatedWithCommit call at once. A burst of up to 2048 calls can hit GitHub's secondary rate limit (about 100 requests at the same time). Then one call fails with 403/429, or one call fails with a normal 5xx. Promise.all rejects, the job fails before addLabels runs, and no pull request in that push gets the label. The old pull_request_target flow labeled each pull request in its own run, so one error never cost other pull requests their label. Fix: look the commits up in small batches, and catch the error for each commit (for example with Promise.allSettled) so that the lookups that worked still lead to labels.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sound, and fixed in aa555be. The commits are looked up ten at a time with Promise.allSettled, so a failed lookup no longer drops the labels of the other pull requests. The script labels what it found and then rethrows the first error, so the run is red and a rerun can finish the job. A failed addLabels is isolated the same way. The docs allow up to 2048 commits in the push payload, so the bound is not only theoretical, although each of the last 40 merges to main was a single commit. Tests: 25 commits never have more than 10 lookups in flight, and a failed lookup or a failed label still labels the others.

# code of a pull request. See that file for details.
concurrency:
group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ github.workflow }}
group: ${{ github.repository }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}-${{ github.workflow }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The concurrency group is the head repository and branch with cancel-in-progress, but the lookup only matches the run's own head SHA. If an older run is queued after a newer one, it cancels the newer run and then labels nothing.

Failure scenario: A contributor pushes twice quickly to a fork branch. If the workflow_run requested event for push 1 is queued after the event for push 2, the push 1 run cancels the push 2 run. pull_requests_of_run.js then drops the pull request, because pull.head.sha no longer equals run.head_sha. Neither run labels the new head, and labels stay stale until the next push. fork_ci_status.yml has the same group shape, but its scheduled sweep fixes the check later. The labeler has no such recovery. Fix: let the run that lost the race use the current head (drop the SHA filter for the labeler), or put the SHA in the group and turn off cancel-in-progress.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sound, and fixed in aa555be with your first suggestion. pull_requests_of_run.js no longer compares head.sha with the run's commit, so the run that survives a cancellation labels the current head. That is safe because actions/labeler computes labels from the files of the PR as it is now, so a delayed run for an older commit gives the same result as the newest one. I kept the group and cancel-in-progress as they are, since putting the SHA in the group would only multiply runs. A test covers a PR whose head moved after the run started.

Comment thread .github/workflows/fork_ci_status.yml Outdated
jobs:
bridge:
name: Mirror the fork run
if: github.event_name != 'workflow_run' || github.event.workflow_run.event == 'pull_request'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The job runs a runner, a checkout and a pulls.list call for every signal run, even when the payload already shows that the head repository is not a fork or was deleted.

Failure scenario: Every push to a pull request from an apache/datafusion branch starts the signal. The bridge job then gets a runner, does the sparse checkout and calls the API, and only after that notify() says 'Not a fork pull request to main'. The same happens for a deleted fork (head_repository is null). Fix: add github.event.workflow_run.head_repository.fork == true to the job-level if for the workflow_run event. That skips these runs before any runner starts, and the release-branch check stays in notify().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in aa555be. For workflow_run events the job if now also requires github.event.workflow_run.head_repository.fork == true. Branches of this repository (Dependabot among them) and deleted forks no longer start a runner, and notify() still checks the base. Tests evaluate the expression for a fork, a branch of this repository, a deleted fork, another triggering event and the scheduled and manual runs.

Comment thread ci/scripts/pull_requests_of_run.js Outdated
head: `${run.head_repository.owner.login}:${run.head_branch}`,
...(base && { base })
});
return data.filter((pull) => pull.head.sha === run.head_sha);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prevent stale signals from cancelling the current label update. For rapid successive pushes, GitHub does not guarantee execution order within a concurrency group. If the older signal's consumer arrives last, labeler.yml cancels the newer consumer because both share the branch-scoped group. This filter then discards the older SHA, so neither run updates the labels, and there is no scheduled label reconciliation. Reconcile the current PR head or ensure obsolete signals cannot cancel its update.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same fix as in the labeler thread. The lookup now returns the open pull requests of the branch as they are, so whichever signal run survives labels the current head. fork_ci_status.yml still compares the commit in notify(). A late run that created the check for the current head could add a second Rust (fork) check to a commit that already has one, and its scheduled sweep covers the cancelled case.

Comment thread ci/scripts/release_version_labeler.js Outdated
// parallel, so the retries of commits without one overlap.
const pushed = (context.payload.commits || []).map((commit) => commit.id);
const shas = pushed.length ? pushed : [context.sha];
const found = await Promise.all(shas.map((sha) => mergedPulls({ github, context, sha })));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very unlikely but for a push containing more than 100 commits, this launches enough simultaneous requests to exceed GitHub's secondary limit of 100 concurrent API requests. A rejected lookup rejects the entire Promise.all before any labels are applied, including for successfully resolved PRs. Use bounded parallelism rather than launching one request per commit; a 101-commit stub reproduces 101 concurrent requests and zero labels after a rate-limit rejection.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Covered by the same change in aa555be. Lookups run ten at a time, and a rejected lookup no longer drops the labels of the others. A 25-commit stub never has more than 10 requests in flight.

@Jefffrey

Jefffrey commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

This PR keeps the current actions/labeler setup and only changes the trigger, so labeling keeps working when pull_request_target is blocked. A bot like Boring Cyborg would need an INFRA ticket for the permissions, so it would be a separate change. If people prefer that route, I am happy to look at it.

should be fine to keep it as actions; i'll probably test it out on arrow-rs instead

- Look up the commits of a push ten at a time, label the pull requests that
  were found when a lookup or a label fails, and fail the run afterwards.
- Return the current pull requests of the branch from the shared lookup, so a
  delayed run for an older commit still labels the latest head. fork_ci_status
  keeps comparing the commit.
- Skip the fork_ci_status job for runs that are not from a fork before a runner
  starts.

@kumarUjjawal kumarUjjawal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@comphead sorry about all the revisions asks 😬

});
// A run for an older commit finds nothing, and the scheduled
// update creates the check of the new head.
const pr = pulls.find(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a second pull request from the same fork branch targets a release branch, its signal run creates a second Rust (fork) check on the pull request to main

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. The old pull_request_target run of the release-branch PR returned early, but the new lookup goes through the branch, so it finds the main PR at the same commit and creates a second check. Fixed in 6ce320a: notify() first lists the Rust (fork) checks of the commit, and when one exists it updates it through the existing syncCheck() instead of creating another. That also covers a reopened PR, which got a second check in the old flow too. A test covers a release-branch PR next to the main PR. It fails both without the lookup and when the existing check is skipped instead of updated.

name: Pull request signal

on:
pull_request:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The signal uses pull_request, and GitHub skips that event when the head commit message contains [skip ci] (or [ci skip], [no ci] and similar). The old pull_request_target triggers ran anyway, and the header comment does not list this gap.**

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. GitHub documents that the markers skip pull_request workflows when they are in the head commit, and that they do not stop pull_request_target. I listed it next to merge conflicts in the header in 6ce320a. The fork's own rust.yml push run is skipped by the same marker, so the scheduled update creates a Rust (fork) check that reports no fork run for the commit, which matches what happened. The labels follow with the next push that has no marker. I also noted that a re-run of the signal does not send requested, which GitHub documents, and how to trigger it again.

errors.push(error);
}
}
if (errors.length > 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only the first error is thrown. The other lookup and label errors are counted but their messages are never logged.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6ce320a. Each failed lookup is logged with its commit, and each failed label with its pull request, when it happens. A summary line follows, and the first error is still thrown so the run is red. A test checks the log lines for a run with one failed lookup and one failed label.

- Update the Rust (fork) check a commit already has instead of adding a second
  one, when a pull request of the same branch to a release branch or a reopened
  pull request signals that commit again.
- Log every failed lookup and label of the release version labeler with its
  commit or pull request.
- Note in the signal workflow that skip markers in the head commit skip it, and
  that a re-run of it does not send requested.
@comphead

comphead commented Nov 9, 2026

Copy link
Copy Markdown
Contributor Author

@kumarUjjawal no need to apologize. Every round found something real, and the PR is better for it. The three points from this round are fixed in 6ce320a.

I also confirmed that Dependabot PRs keep their labels, which the description listed as unverified. The datafusion-ballista labeler was migrated the same way (workflow_run of a pull_request workflow). On 2026-11-09 its runs triggered by Dependabot labeled Dependabot PRs #2823 and #2824, and the repository had no pull_request_target runs since the migration. The release version labeler is not affected either way, because the push of a merged Dependabot PR to main comes from github-merge-queue[bot].

@potiuk

potiuk commented Nov 9, 2026

Copy link
Copy Markdown
Member

@kumarUjjawal no need to apologize. Every round found something real, and the PR is better for it. The three points from this round are fixed in 6ce320a.

💯

Comment on lines 12 to 15
on:
pull_request_target:
types:
- closed
push:
branches:
- main

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recover release labels for merges that skip push workflows. If a squash-merge commit contains [skip ci], [ci skip], or another supported skip marker, GitHub suppresses this push workflow. Unlike the previous pull_request_target: closed trigger, the merged PR then never receives its release-version label. Subsequent pushes only inspect their own commits, so they cannot recover the missing label. Add a reconciliation path that does not depend on the merge's push workflow running.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sound, and it applies more widely than it looks. The merge queue squashes with the PR body as the commit message, so a skip marker anywhere in a PR description reaches main. None of the last 3000 commits on main has one, but this PR's own description had two until today, which I removed. Fixed in c24e43a: each push run now labels every PR merged to main in the last 7 days that has no release label, with the version that release_version_labeler.js names at the PR's merge commit. So a later push labels a merge whose own push skipped the workflow, without depending on that push. This replaces the lookup of the pushed commits. A test checks that dev/update_datafusion_versions.py still rewrites only the version constant, since the script now also reads that constant back at older commits.

Comment thread .github/workflows/fork_ci_status.yml Outdated
// A pull request of the same branch to a release branch, or a
// reopened pull request, signals a commit that can already have
// the check. Update that check instead of adding a second one.
const { data } = await github.rest.checks.listForRef({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

notify() checks for an existing Rust (fork) check before createCheck() starts its wait for the fork run, which can last up to 60 s (12 attempts x 5 s). A check created during that wait is not seen, so the new dedup fails exactly when it is needed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sound. The overlap that matters is with the scheduled update, which has its own concurrency group. Two signals for the same branch share one, so only the newer runs. Fixed in cb97e0b: createCheck() looks for the commit's check only after the wait, and updates it if one exists. The scheduled update's recovery path calls createCheck() too, so it is covered from that side as well. A test creates the check while the run looks for the fork run, and it fails with the earlier order.

Comment thread ci/scripts/release_version_labeler.js Outdated
commit_sha: sha
});
const pulls = data.filter((pull) => pull.merged_at && pull.base.ref === 'main');
if (pulls.length > 0 || attempt === 3) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lookup gives up about 10 s after the push (3 attempts, 5 s apart) and then only logs, so the run stays green. The old pull_request_target: closed trigger fired only after the PR was marked merged and always carried the PR number.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed by the same change in c24e43a. The run no longer looks up the pushed commits, so there is nothing to wait for. It lists the recently merged PRs, and for the last 40 merges merged_at was always set before the push run was created. A PR that a run still misses, or a run that fails, is labeled by the next push.

Comment thread .github/workflows/fork_ci_status.yml Outdated
// A pull request of the same branch to a release branch, or a
// reopened pull request, signals a commit that can already have
// the check. Update that check instead of adding a second one.
const { data } = await github.rest.checks.listForRef({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

notify() calls checks.listForRef(check_name: CHECK) and then syncCheck(pr), which immediately makes the same listForRef call again. That is a redundant API request, and the existence check lives in two places.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in cb97e0b. The existence check now lives only in createCheck(), and notify() calls it directly, so there is one lookup.

Comment thread .github/workflows/fork_ci_status.yml Outdated
}
// Only fork PRs to main skip jobs upstream; release branches have
// no merge queue and keep the full run.
if (!isForkPrToMain(pr)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In notify(), the isForkPrToMain(pr) guard is now dead code. pulls.list is called with base 'main', and the job-level if already requires head_repository.fork == true.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, fixed in cb97e0b. I removed the guard and moved its reason into the comment on the base: 'main' lookup. The scheduled update still uses isForkPrToMain().

Comment thread .github/workflows/labeler.yml Outdated
# This checks out the default branch, never the pull request.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: ci/scripts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every labeler run makes an extra contents API request (and depends on the API fallback path) for a file that the checkout already does could provide. Adding .github/workflows/labeler to sparse-checkout would let the action read the config locally.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed in the source of the pinned actions/labeler: it reads the config from the file when the file exists, and falls back to the contents API otherwise. Fixed in cb97e0b by adding .github/workflows/labeler to the sparse checkout. The checkout is the default branch, so the content is the same.

## CI Workflow Tests

The [Rust workflow validator tests] and [fork status bridge tests] run in CI and in `./dev/rust_lint.sh`.
The [Rust workflow validator tests], [fork status bridge tests] and [release version labeler tests] run in CI and in `./dev/rust_lint.sh`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: should we also mention test_cancel_merge_queue_runs.cjs?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in cb97e0b.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating the label is a non-atomic getLabel/createLabel pair, and the push-triggered runs have no concurrency group. Two runs close together can both get a 404 and both call createLabel; the loser throws 422 before labeling anything.
Treating 422 from createLabel as success would fix it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sound, although it only matters right after a version bump, while the new label does not exist yet. Fixed in cb97e0b: a 422 from createLabel counts as success. A test covers it. With c24e43a, a run that fails anyway is also recovered by the next push.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

Every push to main writes the write-scoped GITHUB_TOKEN into the workspace's git config, where any later step or action can read it. It also clones the whole repository when only ci/scripts is needed, which is inconsistent with the hardening this PR applies to labeler.yml and fork_ci_status.yml.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in cb97e0b: a sparse checkout of ci/scripts with persist-credentials: false, like the other two workflows.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this also use commit SHA like others?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, fixed in cb97e0b. It was the only use of actions/github-script by tag on main.

- Look for the commit's Rust (fork) check after waiting for the fork run, in
  createCheck, so a check created meanwhile is updated instead of duplicated,
  and drop the earlier lookup in notify() along with its dead fork guard.
- Check out the labeler config so actions/labeler reads it from the file.
- Treat a 422 from createLabel as success, since another run can create the
  release label first.
- Use a sparse checkout without persisted credentials in the release version
  labeler, and pin actions/github-script by commit.
- List the merge queue cleanup tests with the other CI workflow tests.
Each run of the release version labeler now labels the pull requests merged to
main in the last week that have no release label, with the version that the
script names at their merge commit. A later push thereby labels a merge whose
own push did not run the workflow, for example because of a skip marker in the
commit message, or whose run failed. This replaces the lookup of the pushed
commits and its retries.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

development-process Related to development process of DataFusion

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Migrate workflows away from pull_request_target

5 participants