Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 113 additions & 15 deletions .github/workflows/commit-queue.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,30 +30,128 @@ jobs:
outputs:
numbers: ${{ steps.get_mergeable_prs.outputs.numbers }}
steps:
- name: Get Pull Requests
id: get_mergeable_prs
- name: Get Pull Request Candidates
id: get_candidate_prs
run: |
prs=$(gh pr list \
list_prs() {
gh pr list \
--repo "$GITHUB_REPOSITORY" \
--base "$GITHUB_REF_NAME" \
--label 'commit-queue' \
"$@" \
--json 'number' \
--search "created:<=$(date --date="2 days ago" +"%Y-%m-%dT%H:%M:%S%z") -label:blocked" \
-t '{{ range . }}{{ .number }} {{ end }}' \
--limit 100)
fast_track_prs=$(gh pr list \
--repo "$GITHUB_REPOSITORY" \
--base "$GITHUB_REF_NAME" \
--label 'commit-queue' \
--limit 100
}
ready_prs=$(list_prs \
--search "created:<=$(date --date="2 days ago" +"%Y-%m-%dT%H:%M:%S%z") -label:blocked")
fast_track_prs=$(list_prs \
--label 'fast-track' \
--search "-label:blocked" \
--json 'number' \
-t '{{ range . }}{{ .number }} {{ end }}' \
--limit 100)
numbers=$(echo $prs' '$fast_track_prs | jq -r -s 'unique | join(" ")')
echo "numbers=$numbers" >> "$GITHUB_OUTPUT"
--search "-label:blocked")
queued_prs=$(list_prs \
--search "-label:blocked")
candidates=$(printf '%s %s %s\n' "$ready_prs" "$fast_track_prs" "$queued_prs" |
jq -r -s 'reduce .[] as $pr ([]; if index($pr) then . else . + [$pr] end) | join(" ")')
echo "candidates=$candidates" >> "$GITHUB_OUTPUT"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Install Node.js
if: steps.get_candidate_prs.outputs.candidates != ''
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install @node-core/utils
if: steps.get_candidate_prs.outputs.candidates != ''
run: npm install -g @node-core/utils

- name: Filter Pull Requests
if: steps.get_candidate_prs.outputs.candidates != ''
id: get_mergeable_prs
run: |
fail_selector() {
echo "$1"
exit 1
}

repository="${GITHUB_REPOSITORY#*/}"
readme="${RUNNER_TEMP}/README.md"
readme_base64="${RUNNER_TEMP}/README.md.base64"

ncu-config set branch "${GITHUB_REF_NAME}"
ncu-config set username "$USERNAME"
ncu-config set token "$GITHUB_TOKEN"
ncu-config set jenkins_token "$JENKINS_TOKEN"
ncu-config set repo "$repository"
ncu-config set owner "${GITHUB_REPOSITORY_OWNER}"

if ! gh api "repos/${GITHUB_REPOSITORY}/contents/README.md?ref=${GITHUB_REF_NAME}" \
--jq '.content' > "$readme_base64"; then
fail_selector "failed to download README.md"
fi

if ! base64 -d < "$readme_base64" > "$readme"; then
fail_selector "failed to decode README.md"
fi

numbers=
# shellcheck disable=SC2086
for pr in $CANDIDATES; do
metadata="${RUNNER_TEMP}/metadata-${pr}.json"
output="${RUNNER_TEMP}/metadata-${pr}.txt"
if git node metadata "$pr" \
--owner "$GITHUB_REPOSITORY_OWNER" \
--repo "$repository" \
--readme "$readme" \
--json > "$metadata" 2> "$output"; then
metadata_status=0
else
metadata_status=$?
fi

if [ -s "$output" ]; then
cat "$output"
fi

case "$metadata_status" in
0|2[0-9]|4[0-9]) ;;
*)
fail_selector "git node metadata failed for pr ${pr} with exit code ${metadata_status}"
;;
esac

metadata_exit_code=$(jq -r '.exitCode' "$metadata") ||
fail_selector "failed to parse metadata JSON for pr ${pr}"
if [ "$metadata_exit_code" != "$metadata_status" ]; then
fail_selector "metadata JSON exitCode mismatch for pr ${pr}"
fi
metadata_reason_codes=$(jq -r '.reasonCodes | join(", ")' "$metadata") ||
fail_selector "failed to parse metadata reason codes for pr ${pr}"

if [ "$metadata_status" -eq 0 ]; then
echo "pr ${pr} is ready for the commit queue"
numbers="$numbers $pr"
continue
fi

if [ "$metadata_status" -ge 20 ] && [ "$metadata_status" -le 29 ]; then
echo "pr ${pr} skipped, not ready to land"
echo "reason codes: ${metadata_reason_codes}"
continue
fi

echo "pr ${pr} will be handled by the commit queue"
echo "reason codes: ${metadata_reason_codes}"
numbers="$numbers $pr"
done

numbers=$(echo "$numbers" | xargs)
echo "numbers=$numbers" >> "$GITHUB_OUTPUT"
env:
CANDIDATES: ${{ steps.get_candidate_prs.outputs.candidates }}
USERNAME: ${{ secrets.JENKINS_USER }}
GITHUB_TOKEN: ${{ secrets.GH_USER_TOKEN }}
JENKINS_TOKEN: ${{ secrets.JENKINS_TOKEN }}
commitQueue:
needs: get_mergeable_prs
if: needs.get_mergeable_prs.outputs.numbers != ''
Expand Down
149 changes: 102 additions & 47 deletions doc/contributing/commit-queue.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
# Commit queue

_tl;dr: You can land pull requests by adding the `commit-queue` label to it._
_tl;dr: You can ask the queue to land pull requests by adding the
`commit-queue` label to them._

Commit Queue is a feature for the project which simplifies the
landing process by automating it via GitHub Actions. With it, collaborators can
land pull requests by adding the `commit-queue` label to a PR. All
checks will run via `@node-core/utils`, and if the pull request is ready to
land, the Action will rebase it and push to `main`.
queue pull requests for landing by adding the `commit-queue` label to a PR. The
selector checks readiness with `@node-core/utils`. If the selector determines
that the pull request is not ready to land yet, the queue will leave the label
in place and retry later. If it is ready, the Action will rebase it and land it
on `main`.

This document gives an overview of how the Commit Queue works, as well as
implementation details, reasoning for design choices, and current limitations.
Expand All @@ -15,22 +18,31 @@ implementation details, reasoning for design choices, and current limitations.

From a high-level, the Commit Queue works as follow:

1. Collaborators will add `commit-queue` label to pull requests ready to land
2. Every five minutes the queue will do the following for each mergeable pull request
with the label:
1. Check if the PR also has a `request-ci` label (if it has, skip this PR
1. Collaborators will add `commit-queue` label to pull requests they want the
queue to land. The label can be added before the pull request has completed
its wait time or approvals, or before requested CI has finished. The commit
queue does not request CI on its own.
2. On each scheduled run, the queue builds a candidate list from open pull
requests with the label. The workflow uses a five-minute cron, but GitHub
Actions scheduled workflows are not guaranteed to run exactly every five
minutes. For each candidate, the queue will:
1. Run a metadata-only readiness check that uses `@node-core/utils` without
checking out the repository
2. If the metadata check exits with a deferrable readiness code, meaning
the PR is only waiting for approvals, TSC approval count, or wait time,
keep the `commit-queue` label and skip this PR until a later queue run
3. Check if the PR also has a `request-ci` label (if it has, skip this PR
since it's pending a CI run)
2. Check if the last Jenkins CI is finished running (if it is not, skip this
PR)
3. Remove the `commit-queue` label
4. Run `git node land <pr> --oneCommitMax`
5. If it fails:
4. Check whether GitHub checks are still running (if they are, skip this PR)
5. Remove the `commit-queue` label and run
`git node land <pr> --oneCommitMax`
6. If it fails:
1. Abort `git node land` session
2. Add `commit-queue-failed` label to the PR
3. Leave a comment on the PR with the output from `git node land`
4. Skip next steps, go to next PR in the queue
6. If it succeeds:
1. Push the changes to nodejs/node
7. If it succeeds:
1. Push or merge the changes into nodejs/node
2. Leave a comment on the PR with `Landed in ...`
3. Close the PR
4. Go to next PR in the queue
Expand All @@ -51,18 +63,23 @@ of the commit queue:
guidelines or be a valid [`fixup!`](https://git-scm.com/docs/git-commit#Documentation/git-commit.txt---fixupamendrewordltcommitgt)
commit that will be correctly handled by the [`--autosquash`](https://git-scm.com/docs/git-rebase#Documentation/git-rebase.txt---autosquash)
option
2. A CI must've ran and succeeded since the last change on the PR
2. A CI must have run and succeeded since the last change on the PR
3. A collaborator must have approved the PR since the last change
4. Only Jenkins CI and GitHub Actions are checked (V8 CI and CITGM are ignored)
5. The PR must target the `main` branch (PRs opened against other branches, such
as backport PRs, are ignored)

## Implementation

The [action](../../.github/workflows/commit-queue.yml) will run on scheduler
events every five minutes. Five minutes is the smallest number accepted by
the scheduler. The scheduler is not guaranteed to run every five minutes, it
might take longer between runs.
The [action](../../.github/workflows/commit-queue.yml) runs on scheduled events.
It uses a five-minute cron because that is the smallest interval accepted by
GitHub Actions. Scheduled workflows are not guaranteed to run exactly at that
cadence and might take longer between runs.

The workflow also uses a concurrency group so only one commit queue run can be
active at a time. If a scheduled run starts while a previous run is still
running, GitHub Actions keeps at most one pending run for the same concurrency
group. A newer pending run replaces an older pending run.

Using the scheduler is preferable over using pull\_request\_target for two
reasons:
Expand All @@ -76,41 +93,79 @@ reasons:
commit, meaning we wouldn't be able to use it for already opened PRs
without rebasing them first.

`@node-core/utils` is configured with a personal token and
a Jenkins token from
[@nodejs-github-bot](https://github.com/nodejs/github-bot).
`octokit/graphql-action` is used to fetch all pull requests with the
`commit-queue` label. The output is a JSON payload, so `jq` is used to turn
that into a list of PR ids we can pass as arguments to
[`commit-queue.sh`](../../tools/actions/commit-queue.sh).

> The personal token only needs permission for public repositories and to read
> profiles, we can use the GITHUB\_TOKEN for write operations. Jenkins token is
`@node-core/utils` is configured with a personal token and a Jenkins token from
[@nodejs-github-bot](https://github.com/nodejs/github-bot). The workflow starts
with a small selector step that uses GitHub CLI to fetch pull requests with the
`commit-queue` label. It first fetches the same age-based and fast-track buckets
the queue used before accepting early queue requests, then fetches the broader
queue and de-duplicates the result. This keeps not-yet-ready PRs from crowding
out PRs that the previous query would have selected if GitHub paginates or caps
a query result.

If there are candidate PRs, the selector installs `@node-core/utils`, downloads
the target branch's README without checking out the repository, and runs
`git node metadata --readme --json` for each candidate. This uses the same
`@node-core/utils` PR readiness checks as `git node land`, but does not clone,
fetch, or merge the PR. The selector consumes the structured metadata result
and its exit code instead of matching human-readable output:

* exit code `0`: the PR is ready and is passed to
[`commit-queue.sh`](../../tools/actions/commit-queue.sh)
* exit codes `20`-`29`: the PR is not ready for a deferrable metadata reason,
so it keeps the `commit-queue` label and is retried later
* exit codes `40`-`49`: the PR has a hard or mixed metadata readiness failure
and is passed to [`commit-queue.sh`](../../tools/actions/commit-queue.sh)

The `20`-`29` exit code range is reserved by `@node-core/utils` for deferrable
metadata readiness states, and `40`-`49` is reserved for hard metadata failure
states. Unknown selector failures fail the workflow before starting the landing
job. PRs passed through with exit code `40`-`49` continue through
`commit-queue.sh` so the queue removes the label, adds `commit-queue-failed`,
and leaves the failure comment in the same place as before.

If the selector cannot run the metadata check, the workflow fails before
starting the landing job. That keeps PR labels unchanged so the queue can retry
on a later scheduled run.

> The personal token needs permission for public repositories and to read
> profiles. It is used by `@node-core/utils` and by the landing job for
> checkout, label and comment updates, merging, and pushing. Jenkins token is
> required to check CI status.

`commit-queue.sh` receives the following positional arguments:

1. The repository owner
2. The repository name
3. The Action GITHUB\_TOKEN
4. Every positional argument starting at this one will be a pull request ID of
3. Every positional argument starting at this one will be a pull request ID of
a pull request with commit-queue set.

The script will iterate over the pull requests. `ncu-ci` is used to check if
the last CI is still pending, and calls to the GitHub API are used to check if
the PR is waiting for CI to start (`request-ci` label). The PR is skipped if CI
is pending. No other CI validation is done here since `git node land` will fail
if the last CI failed.

The script removes the `commit-queue` label. It then runs `git node land`,
forwarding stdout and stderr to a file. If any errors happen,
`git node land --abort` is run, and then a `commit-queue-failed` label is added
to the PR, as well as a comment with the output of `git node land`.

If no errors happen during `git node land`, the script will use the
`GITHUB_TOKEN` to push the changes to `main`, and then will leave a
`Landed in ...` comment in the PR, and then will close it. Iteration continues
until all PRs have done the steps above.
The script will iterate over the pull requests. GitHub CLI is used to check if
the PR is waiting for CI to start (`request-ci` label) or still has pending
GitHub checks. The PR is skipped if CI is pending. No other CI validation is
done here since `git node land` will fail if the last CI failed.

The script removes the `commit-queue` label, then runs `git node land`,
forwarding stdout and stderr to a file. PRs that are only waiting for approval,
wait time, or CI should have already been filtered by the metadata check. If the
PR state changes between the selector job and the landing job, the landing job
uses the same failure path as other `git node land` errors: it runs
`git node land --abort`, adds a `commit-queue-failed` label to the PR, and
leaves a comment with the output of `git node land`.

Fast-tracked PRs use the metadata check before the landing job. If the
fast-track request has not yet received enough collaborator thumbs-up, the queue
keeps the `commit-queue` label and retries until either the fast-track request
is approved or the PR becomes landable through the regular wait-time rules. The
commit queue does not create the fast-track request comment; that is handled
when the `fast-track` label is added. If that comment is missing, the queue
reports the failure instead of keeping the PR queued.

If no errors happen during `git node land`, the script either pushes the direct
rebase landing to `main` or uses GitHub's squash merge API for single-commit and
fixup landings. It then leaves a `Landed in ...` comment in the PR. GitHub
closes PRs merged through the merge API automatically; for direct pushes, the
script closes the PR. Iteration continues until all PRs have done the steps
above.

## Reverting broken commits

Expand Down
Loading