diff --git a/.github/workflows/add-trailer.yml b/.github/workflows/add-trailer.yml new file mode 100644 index 000000000..a7eafcc82 --- /dev/null +++ b/.github/workflows/add-trailer.yml @@ -0,0 +1,128 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2025 Robin Jarry +--- +name: Add Trailer + +on: + issue_comment: + types: [created] + workflow_run: + workflows: ["Review"] + types: [completed] + +permissions: + contents: read + pull-requests: read + actions: read + +jobs: + add-trailer: + if: >- + ${{ (github.event_name == 'issue_comment' && github.event.issue.pull_request + && (contains(github.event.comment.body, 'Acked-by:') || contains(github.event.comment.body, 'Tested-by:'))) + || (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') }} + runs-on: ubuntu-latest + steps: + - name: Get PR info and trailer + id: info + uses: actions/github-script@v7 + with: + script: | + let pr, trailer; + + if (context.eventName === 'issue_comment') { + const body = context.payload.comment.body; + const regexp = /(acked|tested)-by:\s+(\w[^<]+\w)(?:\s+<([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})>)?/i; + const match = body.match(regexp); + if (!match) { + throw new Error(`No valid trailer found in comment: ${body}`) + } + + const { data: prData } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.issue.number + }); + + pr = prData; + trailer = `${match[1]}-by: ${match[2]} <${match[3] || 'UNKNOWN_EMAIL'}>`; + + } else if (context.eventName === 'workflow_run') { + const headBranch = context.payload.workflow_run.head_branch; + + const { data: allPRs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + }); + + const matchingPRs = allPRs.filter(p => p.head.ref === headBranch); + + if (matchingPRs.length === 0) { + throw new Error(`No open PR found for ${headBranch}`) + } + pr = matchingPRs[0]; + + const actor = context.payload.workflow_run.triggering_actor.login; + const req = await fetch(`https://api.github.com/users/${actor}`); + const user = await req.json(); + trailer = `Reviewed-by: ${user.name || actor} <${user.email || 'UNKNOWN_EMAIL'}>`; + } + + return { + base_sha: pr.base.sha, + head_repo: pr.head.repo.full_name, + head_ref: pr.head.ref, + trailer: trailer, + }; + + - uses: actions/checkout@v4 + with: + token: ${{ secrets.TRAILER_BOT_TOKEN }} + repository: ${{ fromJSON(steps.info.outputs.result).head_repo }} + ref: ${{ fromJSON(steps.info.outputs.result).head_ref }} + fetch-depth: 0 + persist-credentials: true + + - name: Amend all pull request commits with the trailer + env: + GIT_TRAILER_DEBUG: 1 + BASE_SHA: ${{ fromJSON(steps.info.outputs.result).base_sha }} + HEAD_REF: ${{ fromJSON(steps.info.outputs.result).head_ref }} + TRAILER: ${{ fromJSON(steps.info.outputs.result).trailer }} + run: | + set -xe + + if echo "$TRAILER" | grep -qw 'UNKNOWN_EMAIL'; then + NAME=$(echo "$TRAILER" | sed -En 's/.+-by:[[:blank:]]+([^<]+)[[:blank:]]<.+/\1/p') + EMAIL=$(git log --pretty=%aE --author="$NAME" | head -n1) + + if [ -z "$EMAIL" ]; then + EMAIL=$(git shortlog -se -w0 --group=author --group=committer \ + --group=trailer:acked-by --group=trailer:reviewed-by \ + --group=trailer:reported-by --group=trailer:signed-off-by \ + --group=trailer:tested-by HEAD | \ + sed -En "s/^[[:space:]]+[0-9]+[[:space:]]+$NAME <([^@]+@[^>]+)>$/\\1/p" | head -n1) + fi + + if [ -z "$EMAIL" ]; then + echo "Error: Could not find email for $NAME" + exit 1 + fi + + TRAILER=$(echo "$TRAILER" | sed "s/\\/$EMAIL/") + fi + + GIT_COMMITTER_NAME=$(git log -1 --pretty=%cN) + GIT_COMMITTER_EMAIL=$(git log -1 --pretty=%cE) + git config set user.name "$GIT_COMMITTER_NAME" + git config set user.email "$GIT_COMMITTER_EMAIL" + export GIT_COMMITTER_NAME GIT_COMMITTER_EMAIL + + rm -f .git/hooks/commit-msg + ln -s ../../devtools/commit-msg .git/hooks/commit-msg + + git rebase "$BASE_SHA" \ + --exec "git commit -C HEAD --no-edit --amend --trailer='$TRAILER'" + + git push --force origin "$HEAD_REF" diff --git a/.github/workflows/apply.sh b/.github/workflows/apply.sh deleted file mode 100755 index efe736776..000000000 --- a/.github/workflows/apply.sh +++ /dev/null @@ -1,168 +0,0 @@ -#!/usr/bin/env bash -# SPDX-License-Identifier: BSD-3-Clause -# Copyright (c) 2025 Robin Jarry - -set -e -o pipefail - -: ${PULL_REQUEST?PULL_REQUEST} -: ${LOGIN?LOGIN} - -# get the full URL pointing to the current github action job -job_url() { - local run_id="$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" - local job_id=$(gh api "repos/$run_id/jobs" --jq ".jobs[] | select(.name==\"$GITHUB_JOB\") | .id") - echo "https://github.com/$run_id/job/$job_id" -} - -# post an error message on the pull request -fail() { - set +e - gh pr comment $PR_NUMBER -b "error: $1 - -$JOB_URL" - exit 1 -} - -# error trap handler -err() { - set +e - gh pr comment $PR_NUMBER -b "error: command \`$BASH_COMMAND\` failed - -$JOB_URL" - exit 1 -} - -# get the full name of the given github account (may not be available) -user_name() { - local login=$1 - local name=$(gh api users/$login --jq '.name') - if [ -z "$name" ] || [ "$name" = null ]; then - fail "user $login does not expose their full name" - fi - echo "$name" -} - -# get the email exposed by the given github account (may not be available) -email_from_gh() { - local login=$1 - gh api users/$login --jq '.email' -} - -# get the most recent email used by that person from git history -email_from_git() { - local name=$1 - git log --pretty=%aE --author="$name" | head -n1 -} - -# get the email from a github account (fallback to looking at github history) -user_email() { - local login=$1 - local name=$2 - local email=$(email_from_gh "$login") - if [ -z "$email" ] || [ "$email" = null ]; then - email=$(email_from_git "$name") - if [ -z "$email" ]; then - fail "user $login does not expose their email and is unknown from git history" - fi - fi - echo "$email" -} - -trap err ERR - -# gather pull request details -PR_JSON=$(gh api "$PULL_REQUEST") -PR_NUMBER=$(echo "$PR_JSON" | jq -r .number) -BASE_REF=$(echo "$PR_JSON" | jq -r .base.ref) -HEAD_REF=$(echo "$PR_JSON" | jq -r .head.ref) -NUM_COMMITS=$(echo "$PR_JSON" | jq -r .commits) -HEAD_URL=$(echo "$PR_JSON" | jq -r .head.repo.clone_url) -JOB_URL=$(job_url) -tmp=$(mktemp -d) -trap "rm -rf -- $tmp" EXIT - -set -x - -# ensure that the person that posted the '/apply' comment has push access -perm=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$LOGIN/permission" --jq '.permission') -if ! [ "$perm" = admin ] && ! [ "$perm" = write ]; then - fail "user $LOGIN does not have permission to apply PRs (permission: $perm)" -fi - -# configure git identity to the person that posted the '/apply' comment -# they will be "committer" of all the rebased commits -GIT_COMMITTER_NAME=$(user_name "$LOGIN") -GIT_COMMITTER_EMAIL=$(user_email "$LOGIN" "$GIT_COMMITTER_NAME") -git config set user.name "$GIT_COMMITTER_NAME" -git config set user.email "$GIT_COMMITTER_EMAIL" -export GIT_COMMITTER_NAME GIT_COMMITTER_EMAIL -rm -f .git/hooks/commit-msg -ln -s ../../devtools/commit-msg .git/hooks/commit-msg - -base_sha=$(git log -1 --pretty=%H HEAD) - -git remote add head "$HEAD_URL" -git fetch head -git checkout -b "$HEAD_REF" "head/$HEAD_REF" -git branch --set-upstream-to="origin/$BASE_REF" - -# fast forward merge the pull request branch on top of the base one -if ! git rebase "origin/$BASE_REF" >"$tmp/rebase" 2>&1; then - fail "rebase failed: -\`\`\` -$(cat $tmp/rebase) -\`\`\`" -fi - -# ensure at least one commit was applied -rebased_sha=$(git log -1 --pretty=%H HEAD) -if [ "$rebased_sha" = "$base_sha" ]; then - fail "branch commits already merged" -fi - -# add a Reviewed-by trailer for every "approved" review -gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews" --paginate \ - --jq '.[] | select(.state=="APPROVED") | .user.login' | sort -u | -while read -r login; do - name=$(user_name "$login") - email=$(user_email "$login" "$name") - echo "Reviewed-by: $name <$email>" -done >> "$tmp/trailers" - -# gather all comments that contain Reviewed-by, Acked-by or Tested-by trailers -trailer_re="[[:space:]]*(Reviewed|Acked|Tested)-by:[[:blank:]]+" # trailer key -trailer_re="$trailer_re([[:alpha:]][^<]*[[:alpha:]])[[:blank:]]+" # full name -trailer_re="$trailer_re?[[:space:]]*" # email -gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --paginate --jq '.[].body' | - sed -En "s/^$trailer_re\$/\\1-by: \\2 <\\3>/p" | sort -u >> "$tmp/trailers" - -sort -u "$tmp/trailers" > "$tmp/trailers-uniq" - -trailers="" -while read -r line; do - trailers="$trailers --trailer '$line'" -done < "$tmp/trailers-uniq" - -if [ -n "$trailers" ]; then - # rewrite all commit messages, appending trailers - # hooks/commit-msg will remove duplicates and ensure correct ordering - GIT_TRAILER_DEBUG=1 git rebase "origin/$BASE_REF" \ - --exec "git commit -C HEAD --no-edit --amend $trailers" - git log --pretty=fuller "origin/$BASE_REF.." -fi - -# fast-forward merge the rebased branch with added trailers and push it manually -git checkout "$BASE_REF" -git merge --ff-only "$HEAD_REF" -git push origin "$BASE_REF" - -# post a comment to identify the new HEAD commit id -sha=$(git log -1 --pretty=%H "origin/$BASE_REF") -gh pr comment "$PR_NUMBER" -b "Pull request applied with git trailers: $sha - -$(job_url)" - -# 'gh pr merge --rebase' will do nothing since the branch was already pushed -# bypass the check and invoke the API endpoint directly -gh api -X PUT "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/merge" \ - -f merge_method=rebase || gh pr close $PR_NUMBER diff --git a/.github/workflows/apply.yml b/.github/workflows/apply.yml deleted file mode 100644 index 7d9160cb9..000000000 --- a/.github/workflows/apply.yml +++ /dev/null @@ -1,32 +0,0 @@ -# SPDX-License-Identifier: BSD-3-Clause -# Copyright (c) 2025 Robin Jarry ---- -name: Apply PR - -on: - issue_comment: - types: [created] - -permissions: - contents: write - pull-requests: write - -jobs: - apply: - if: github.event.issue.pull_request != null && github.event.comment.body == '/apply' - runs-on: ubuntu-latest - env: - GH_TOKEN: ${{ github.token }} - PULL_REQUEST: ${{ github.event.issue.pull_request.url }} - LOGIN: ${{ github.event.comment.user.login }} - - steps: - - run: gh auth setup-git - - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - fetch-tags: true - persist-credentials: false - - - run: .github/workflows/apply.sh diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 7970cb1f8..e72c0734a 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -18,6 +18,7 @@ concurrency: jobs: build-and-tests: + if: ${{ github.actor != 'grout-bot' }} strategy: fail-fast: false matrix: @@ -108,6 +109,7 @@ jobs: - run: ccache -sv build-cross-aarch64: + if: ${{ github.actor != 'grout-bot' }} runs-on: ubuntu-24.04 container: debian:testing env: @@ -147,6 +149,7 @@ jobs: - run: ccache -sv lint: + if: ${{ github.actor != 'grout-bot' }} runs-on: ubuntu-24.04 container: fedora:latest steps: diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 02c71eebd..e1b8e69cc 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -14,6 +14,7 @@ on: jobs: deb: + if: ${{ github.actor != 'grout-bot' }} permissions: actions: write runs-on: ubuntu-24.04 @@ -63,6 +64,7 @@ jobs: retention-days: 5 rpm: + if: ${{ github.actor != 'grout-bot' }} permissions: actions: write runs-on: ubuntu-24.04 diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml new file mode 100644 index 000000000..367964ec8 --- /dev/null +++ b/.github/workflows/review.yml @@ -0,0 +1,19 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2025 Robin Jarry +--- +name: Review + +on: + pull_request_review: + types: [submitted] + +permissions: + contents: read + +jobs: + approved: + if: github.event.review.state == 'approved' + runs-on: ubuntu-latest + steps: + - name: Review approved + run: echo "PR approved by ${{ github.event.review.user.login }}"