diff --git a/.github/workflows/pr-signoff.yml b/.github/workflows/pr-signoff.yml new file mode 100644 index 0000000..ef68ca9 --- /dev/null +++ b/.github/workflows/pr-signoff.yml @@ -0,0 +1,99 @@ +# Reusable workflow implementing the `/signoff []` and +# `/rebase-signoff []` pull request commands. The logic is in +# pr-signoff/pr-signoff.sh, which also has the security notes; see the +# "pr-signoff" section of README.md for how a repository installs this. +name: PR signoff command + +on: + workflow_call: + inputs: + app-client-id: + description: >- + Client ID (or app ID) of a GitHub App installed on the repository + with contents and pull-requests write access. Its token is used so + that the rewritten branch triggers CI again, and it is required for + fork PRs. Without it the job falls back to the caller's + GITHUB_TOKEN, whose pushes don't trigger workflows and can't reach + forks. + type: string + default: '' + workflows-permission: + description: >- + Also request the App's workflows permission, needed to push PRs + that change .github/workflows. The App must have it. + type: boolean + default: false + secrets: + app-private-key: + description: Private key of that GitHub App. + required: false + +jobs: + signoff: + # Callers should filter on the comment prefix themselves (see README), so + # unrelated comments never get this far; this is defense in depth. + if: github.event_name == 'issue_comment' && github.event.issue.pull_request + runs-on: ubuntu-24.04 + timeout-minutes: 10 + # Used only without an App, for same-repository PRs; the caller grants it. + permissions: + contents: write + pull-requests: write + # One command per PR at a time. Don't cancel: a queued command then sees + # the branch the first one pushed, and its SHA check refuses it. + concurrency: + group: pr-signoff-${{ github.repository }}-${{ github.event.issue.number }} + cancel-in-progress: false + env: + HAVE_APP: ${{ inputs.app-client-id != '' && secrets.app-private-key != '' }} + steps: + - name: Generate App token + id: app-token + if: env.HAVE_APP == 'true' + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ inputs.app-client-id }} + private-key: ${{ secrets.app-private-key }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-pull-requests: write + permission-workflows: ${{ inputs.workflows-permission && 'write' || '' }} + + # The script, from the same commit of bootc-dev/actions as this + # workflow (which the caller pins). Nothing from the PR is checked out. + - name: Check out pr-signoff + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .pr-signoff + sparse-checkout: pr-signoff + persist-credentials: false + + - name: Run command + id: run + env: + GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.issue.number }} + COMMENT_ID: ${{ github.event.comment.id }} + COMMENT_BODY: ${{ github.event.comment.body }} + COMMENT_CREATED_AT: ${{ github.event.comment.created_at }} + COMMENTER: ${{ github.event.comment.user.login }} + COMMENTER_TYPE: ${{ github.event.comment.user.type }} + SERVER_URL: ${{ github.server_url }} + run: .pr-signoff/pr-signoff/pr-signoff.sh + + - name: Report failure + if: failure() && steps.run.outputs.replied != 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.issue.number }} + COMMENT_ID: ${{ github.event.comment.id }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + shell: bash + run: | + gh api -X POST "repos/$REPO/issues/comments/$COMMENT_ID/reactions" -f content=confused > /dev/null || true + gh api -X POST "repos/$REPO/issues/$PR_NUMBER/comments" \ + -f body=":x: The signoff command failed unexpectedly; see the [workflow run]($RUN_URL)." > /dev/null diff --git a/.github/workflows/test-actions-pr.yml b/.github/workflows/test-actions-pr.yml index a99a745..2a0fc4e 100644 --- a/.github/workflows/test-actions-pr.yml +++ b/.github/workflows/test-actions-pr.yml @@ -8,6 +8,16 @@ permissions: contents: read jobs: + test-pr-signoff: + name: Test pr-signoff + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Run offline tests + run: pr-signoff/test.sh + test-bootc-ubuntu-setup: name: Test bootc-ubuntu-setup runs-on: ubuntu-26.04 diff --git a/README.md b/README.md index b94c1cc..6fe162d 100644 --- a/README.md +++ b/README.md @@ -2,3 +2,114 @@ At the current time, these actions are not intended for use outside of the organization. + +## pr-signoff + +A reusable workflow implementing two pull request comment commands for +repositories that require the [DCO](https://developercertificate.org/) +check. The logic is [pr-signoff/pr-signoff.sh](pr-signoff/pr-signoff.sh), +with offline tests in `pr-signoff/test.sh`. + +- `/signoff` adds the commenter's `Signed-off-by` to every commit of the + PR that lacks it. The commits keep their base, trees, author + lines and messages byte for byte; only the trailer and the committer + change (they are recreated with git plumbing, not rebased). +- `/rebase-signoff` does the same, but moves the commits onto the current + tip of the target branch, dropping any that become empty. + +Either command only signs off a head that GitHub's activity log for the +head branch shows was pushed before the comment was posted, so a push +racing the command is never signed off (commit dates can't be used for +this, since the PR author controls them). An optional `` (12 to 40 +hex digits; shorter prefixes can be forged) must also match the head: pass +it when there's any doubt the head is what you reviewed, since without it +a push between your review and your comment would be signed off too. The +push uses `--force-with-lease` against that head, so a concurrent push is +never overwritten. In repositories that dismiss stale reviews, the push can +dismiss approvals like any push, so sign off before approving. + +Who can use it: `/signoff` needs the triage role or higher, but write on +PRs from forks, since the App's push runs their CI without the "approve and +run" step GitHub otherwise requires for outside contributors. +`/rebase-signoff` changes the PR's content, like the "Update branch" +button, so it needs write. Comments by bots and by people without access +to the repository are ignored silently. + +The trailer uses the commenter's GitHub name and public email, falling +back to their `ID+login@users.noreply.github.com` address; the commenter +also becomes the committer, which is what the `dco-2` App matches a +sign-off against when it isn't the author's. dco-2 re-checks the PR on the +push, and a push with the App token also runs the PR's CI again. Pushing +removes a PR from the merge queue, as any push does. + +The command reacts with :eyes: when it starts, then :rocket: or :confused:, +and posts one comment with the result. Commands on the same PR run one at +a time, and GitHub keeps only the newest one waiting: a command sent while +another is already waiting replaces it, and the replaced one gets no +reply. PRs with merge commits are refused, as are fork PRs that don't allow +edits by maintainers and PRs whose head is a branch of this repository +that is the default, protected, or covered by a rule against force pushes. +Rewritten commits lose any GPG/SSH signature their author made. + +### Installing it + +Each repository needs this stub as `.github/workflows/signoff.yml`. In +bootc-dev, [infra](https://github.com/bootc-dev/infra) syncs it to every +repository from `common/.github/workflows/signoff.yml`, so there's nothing +to do per repository; other organizations can copy it: + +```yaml +name: PR signoff command + +on: + issue_comment: + types: [created] + +permissions: {} + +jobs: + signoff: + # GitHub evaluates this before assigning a runner: any other comment + # creates a skipped run, which uses no runner time. + if: >- + github.event.issue.pull_request && + github.event.comment.user.type == 'User' && + (startsWith(github.event.comment.body, '/signoff') || + startsWith(github.event.comment.body, '/rebase-signoff')) + # Only used where the App below isn't configured, for same-repository + # PRs; the job runs no code from the PR. + permissions: + contents: write + pull-requests: write + # Pinned: this hands the App's private key to the called workflow. + uses: bootc-dev/actions/.github/workflows/pr-signoff.yml@ # main + with: + app-client-id: ${{ vars.GH_AW_APP_CLIENT_ID }} + workflows-permission: true + secrets: + app-private-key: ${{ secrets.GH_AW_APP_PRIVATE_KEY }} +``` + +Pin a commit of this repository's main branch, since the stub hands the +App's private key to the called workflow, which runs its script from that +same commit. Update the pin in infra's `common/` copy and sync-common +carries it to every repository. + +The workflow is public, so repositories in any organization can call it. +It needs a GitHub App installed on the repository with contents and pull +requests write access (and workflows write, for `workflows-permission: +true`, to push PRs that change `.github/workflows`), whose client ID is +the `GH_AW_APP_CLIENT_ID` variable and private key the +`GH_AW_APP_PRIVATE_KEY` secret. In bootc-dev that is the bootc-bot App, +which has these permissions; another organization needs an App of its own +(or that one installed) and the same variable and secret. The App token is +what makes the rewritten branch run CI again, and what can push to forks. +Without it the workflow falls back to the job's `GITHUB_TOKEN`: dco-2 +still re-checks, but other workflows don't run on the new head, and fork +PRs are refused. + +The job-level `if:` matters: GitHub can't filter `issue_comment` events on +their body in `on:`, so every comment on an issue or PR creates a run of +the stub. With the `if:` false, that run's job is skipped without ever +requesting a runner: it costs no runner time, and only shows up as a +skipped run in the Actions tab. diff --git a/pr-signoff/pr-signoff.sh b/pr-signoff/pr-signoff.sh new file mode 100755 index 0000000..888fd1e --- /dev/null +++ b/pr-signoff/pr-signoff.sh @@ -0,0 +1,307 @@ +#!/usr/bin/env bash +# The /signoff and /rebase-signoff pull request commands, run by +# .github/workflows/pr-signoff.yml; see README.md ("pr-signoff"). +# +# Security notes, since this force-pushes PR branches (including forks) +# with a token that can write to the repository: +# - Nothing from the PR is executed or checked out: commits are rewritten in +# a bare repository with git plumbing (hash-object, merge-tree), hooks +# disabled, system/global config ignored and no attributes from the PR. +# - The comment body, branch names and user names arrive only through the +# environment, and replies never echo PR-controlled text. +# - Only a head that GitHub recorded being pushed before the comment is +# signed off (and the one named, if a SHA is given); the push uses +# --force-with-lease against it. +# +# Environment: GH_TOKEN, HAVE_APP (true/false), REPO, PR_NUMBER, +# COMMENT_ID, COMMENT_BODY, COMMENT_CREATED_AT, COMMENTER, COMMENTER_TYPE, +# SERVER_URL, GITHUB_RUN_ID, GITHUB_OUTPUT. +# shellcheck disable=SC2153 # COMMENTER and friends come from the environment +set -euo pipefail +shopt -s inherit_errexit +export LC_ALL=C + +declare -rA ROLE_RANK=([none]=0 [read]=1 [triage]=2 [write]=3 [maintain]=4 [admin]=5) +# A shorter prefix could be matched by a commit the PR author ground out. +readonly MIN_SHA_LEN=12 +readonly SHA_RE="^[0-9a-f]{${MIN_SHA_LEN},40}$" +# Like gh-aw's slash commands: the command is the first word of the comment. +readonly COMMAND_RE='^/(signoff|rebase-signoff)([[:space:]].*)?$' +readonly SIGNOFF_KEY=Signed-off-by +# Branch rule types that mean a branch must not be force-pushed. +readonly NO_REWRITE_RULES='["non_fast_forward", "update"]' + +# parse_command BODY: sets COMMAND and ARGS from the first line of a comment, +# or returns 1 if it isn't one of the commands. +parse_command() { + local first=${1%%$'\n'*} + first=${first%$'\r'} + [[ $first =~ $COMMAND_RE ]] || return 1 + COMMAND=${BASH_REMATCH[1]} + read -r -a ARGS <<< "${BASH_REMATCH[2]:-}" +} + +# effective_role PERMISSION_JSON: the commenter's role from the collaborator +# permission API. Like gh-aw's check_membership, role_name is preferred (the +# legacy "permission" folds triage into read and maintain into write), and a +# custom role falls back to its base permission, never admin. +effective_role() { + local role + role=$(jq -r '.role_name // "" | if . == "maintainer" then "maintain" else . end' <<< "$1") + if [[ -n $role && -n ${ROLE_RANK[$role]:-} ]]; then + echo "$role" + return + fi + case $(jq -r '.permission // "none"' <<< "$1") in + write) echo write ;; + read) echo read ;; + *) echo none ;; + esac +} + +# required_role COMMAND FORK: signing off needs triage, like other PR +# housekeeping. Rebasing changes the PR's content, like "Update branch", so +# it needs write. On a fork PR, pushing with the App token makes the PR's CI +# run without the "approve and run" gate for outside contributors, which +# needs write too. +required_role() { + if [[ $1 == rebase-signoff || $2 == true ]]; then + echo write + else + echo triage + fi +} + +# role_allows ROLE REQUIRED: whether ROLE is REQUIRED or higher. +role_allows() { + (( ROLE_RANK[$1] >= ROLE_RANK[$2] )) +} + +# rewrite MODE ONTO COMMIT...: recreates the commits (oldest first) on ONTO +# with TRAILER added unless the same trailer is already there, and with +# COMMITTER as the committer; prints the new head. In "signoff" mode ONTO is +# their original base and every tree is kept; in "rebase-signoff" mode each +# tree comes from merge-tree, commits that become empty are dropped (like +# git rebase, ones that were empty to begin with are kept), and a conflict +# returns 2. Everything but the trailer and the +# committer is copied byte for byte from the original commit object, +# including the author line and the message; signatures are dropped. +rewrite() { + local mode=$1 parent=$2 c orig_parent tree raw msg rc + shift 2 + raw=$(mktemp) + msg=$(mktemp) + for c in "$@"; do + git cat-file commit "$c" > "$raw" + orig_parent=$(git rev-parse "$c^") + if [[ $mode == signoff ]]; then + tree=$(git rev-parse "$c^{tree}") + else + rc=0 + tree=$(git merge-tree --write-tree --no-messages --merge-base="$orig_parent" "$parent" "$c") || rc=$? + if (( rc != 0 )); then + rm -f "$raw" "$msg" + return 2 + fi + if [[ $tree == "$(git rev-parse "$parent^{tree}")" && + $(git rev-parse "$c^{tree}") != "$(git rev-parse "$orig_parent^{tree}")" ]]; then + continue + fi + fi + sed '1,/^$/d' "$raw" | + git interpret-trailers --no-divider --if-exists addIfDifferent \ + --trailer "$TRAILER" > "$msg" + if [[ $parent == "$orig_parent" ]] && sed '1,/^$/d' "$raw" | cmp -s - "$msg"; then + parent=$c + continue + fi + parent=$({ + printf 'tree %s\nparent %s\n' "$tree" "$parent" + sed -n '/^$/q; /^author /p' "$raw" + printf 'committer %s\n' "$COMMITTER" + sed -n '/^$/q; /^encoding /p' "$raw" + echo + cat "$msg" + } | git hash-object -t commit -w --stdin) + done + rm -f "$raw" "$msg" + echo "$parent" +} + +api() { gh api -H 'X-GitHub-Api-Version: 2022-11-28' "$@"; } +react() { + api -X POST "repos/$REPO/issues/comments/$COMMENT_ID/reactions" \ + -f content="$1" > /dev/null || echo "::warning::Failed to add a $1 reaction" +} +# Posts the one result comment and ends the job successfully. +finish() { + react "$1" + api -X POST "repos/$REPO/issues/$PR_NUMBER/comments" -f body="$2" > /dev/null + echo "replied=true" >> "$GITHUB_OUTPUT" + exit 0 +} +refuse() { + echo "::notice::Refused: $1" + finish confused ":x: @$COMMENTER \`/$COMMAND\`: $1" +} +short() { printf '%.7s' "$1"; } + +main() { + parse_command "$COMMENT_BODY" || { echo "Not a signoff command; nothing to do."; exit 0; } + # Others (bots, apps) are ignored silently, as are commenters who can't + # even read the repository, so the command can't be used for noise. + if [[ $COMMENTER_TYPE != User ]]; then + echo "::notice::Ignoring a command from $COMMENTER ($COMMENTER_TYPE)" + exit 0 + fi + local perm_json role + perm_json=$(api "repos/$REPO/collaborators/$COMMENTER/permission") || perm_json='{}' + role=$(effective_role "$perm_json") + echo "Commenter $COMMENTER has role: $role" + if ! role_allows "$role" read; then + echo "::notice::Ignoring a command from $COMMENTER, who has no access" + exit 0 + fi + react eyes + + local pr_json head_sha head_ref head_repo base_ref fork=false required + pr_json=$(api "repos/$REPO/pulls/$PR_NUMBER") + pr() { jq -r "$1" <<< "$pr_json"; } + head_sha=$(pr .head.sha) + head_ref=$(pr .head.ref) + head_repo=$(pr '.head.repo.full_name // ""') + base_ref=$(pr .base.ref) + [[ -n $head_repo && $head_repo != "$REPO" ]] && fork=true + required=$(required_role "$COMMAND" "$fork") + if ! role_allows "$role" "$required"; then + refuse "this command requires the $required role or higher on this repository$([[ $fork == true && $COMMAND == signoff ]] && echo " for PRs from forks, since the push runs their CI")." + fi + [[ $(pr .state) == open ]] || refuse "the pull request is not open." + [[ -n $head_repo ]] || refuse "the PR's source repository no longer exists." + local ref + for ref in "$head_ref" "$base_ref"; do + git check-ref-format "refs/heads/$ref" || refuse "unsupported branch name." + done + + # Which head: one pushed before the comment was posted, per GitHub's + # activity log (commit dates can't be used; the PR author sets them), + # and the named one if a SHA is given. What the maintainer looked at + # before commenting can't be known; naming the SHA pins that. + local usage want push_json pushed_sha pushed_at pushed_ts comment_ts + usage="Usage: \`/$COMMAND []\`, where the optional \`\` ($MIN_SHA_LEN to 40 hex digits) must match the PR's head." + (( ${#ARGS[@]} <= 1 )) || refuse "$usage" + if (( ${#ARGS[@]} == 1 )); then + want=${ARGS[0],,} + [[ $want =~ $SHA_RE ]] || refuse "$usage" + [[ $head_sha == "$want"* ]] || + refuse "the PR head is now $head_sha, not $want. Review the new commits, then retry." + fi + push_json=$(api -X GET "repos/$head_repo/activity" -f ref="refs/heads/$head_ref" \ + -f direction=desc -f per_page=1 --jq '.[0] // {} | {after, timestamp}') || push_json='{}' + pushed_sha=$(jq -r '.after // ""' <<< "$push_json") + pushed_at=$(jq -r '.timestamp // ""' <<< "$push_json") + echo "Last push to the head branch: $pushed_sha at $pushed_at; comment at $COMMENT_CREATED_AT" + [[ $pushed_sha == "$head_sha" && -n $pushed_at ]] || + refuse "couldn't confirm when the PR head $(short "$head_sha") was pushed; please retry in a minute." + pushed_ts=$(date -d "$pushed_at" +%s) + comment_ts=$(date -d "$COMMENT_CREATED_AT" +%s) + (( pushed_ts < comment_ts )) || + refuse "the PR was pushed after your comment (head is now $(short "$head_sha")). Review the new commits, then retry." + + # Where we'd push: never a shared branch that rules keep from rewrites. + if [[ $fork == false ]]; then + local protected blocked + protected=$(api -X GET "repos/$REPO/branches" -f protected=true --paginate --jq '.[].name') + blocked=$(api "repos/$REPO/rules/branches/$head_ref" --jq "[.[] | select(.type | IN(${NO_REWRITE_RULES}[]))] | length") + if [[ $head_ref == "$(pr .base.repo.default_branch)" ]] || grep -qxF -- "$head_ref" <<< "$protected" || (( blocked > 0 )); then + refuse "the PR's head is a protected branch of this repository, which this command won't rewrite." + fi + else + [[ $(pr .maintainer_can_modify) == true ]] || + refuse "this PR is from a fork that doesn't allow edits by maintainers. The author can enable \"Allow edits by maintainers\" on the PR, or sign off the commits themselves with \`git rebase --signoff\`." + [[ $HAVE_APP == true ]] || + refuse "pushing to fork PRs needs a GitHub App token, and this repository's workflow doesn't provide one." + fi + + # The commenter signs off and is the committer, which is what dco-2 + # matches a sign-off against when it isn't the author's. + local user_json name email + user_json=$(api "users/$COMMENTER") + name=$(jq -r '.name // ""' <<< "$user_json") + email=$(jq -r '.email // ""' <<< "$user_json") + [[ -n $name && $name != *[$'<>\n']* ]] || name=$COMMENTER + [[ -n $email && $email != *[$'<> \n']* ]] || email="$(jq -r .id <<< "$user_json")+$COMMENTER@users.noreply.github.com" + TRAILER="$SIGNOFF_KEY: $name <$email>" + COMMITTER="$name <$email> $(date +%s) +0000" + echo "Signing off as: $name <$email>" + + # A bare repository with no hooks, no system/global config, no + # attributes from any tree, and only https. + export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0 + GIT_DIR=$(mktemp -d) + export GIT_DIR + git init -q --bare "$GIT_DIR" + git config core.hooksPath /dev/null + git config protocol.allow never + git config protocol.https.allow always + printf '* merge=text -filter\n' > "$GIT_DIR/info/attributes" + # shellcheck disable=SC2016 # expanded by git's shell, from the environment + git config credential.helper '!f() { test "$1" = get && echo username=x-access-token && echo "password=$GH_TOKEN"; }; f' + git remote add origin "$SERVER_URL/$REPO.git" + # refs/pull/N/head mirrors the head in the base repository, fork PRs + # included. + git fetch -q --no-tags --no-recurse-submodules origin \ + "+refs/heads/$base_ref:refs/remotes/base" "+refs/pull/$PR_NUMBER/head:refs/remotes/pr" + [[ $(git rev-parse refs/remotes/pr) == "$head_sha" ]] || + refuse "GitHub hasn't updated this PR's refs yet; please retry in a minute." + + local base_tip merge_base onto new_head rc=0 + local -a commits + base_tip=$(git rev-parse refs/remotes/base) + merge_base=$(git merge-base "$base_tip" "$head_sha") || + refuse "the PR has no history in common with the target branch." + [[ -z $(git rev-list --merges "$merge_base..$head_sha") ]] || + refuse "the PR contains merge commits; please rebase it without merges first." + mapfile -t commits < <(git rev-list --reverse "$merge_base..$head_sha") + (( ${#commits[@]} > 0 )) || refuse "the PR has no commits of its own." + onto=$merge_base + [[ $COMMAND == signoff ]] || onto=$base_tip + new_head=$(rewrite "$COMMAND" "$onto" "${commits[@]}") || rc=$? + (( rc != 2 )) || + refuse "the commits don't apply cleanly onto the target branch (at $(short "$base_tip")); the PR needs a manual rebase." + (( rc == 0 )) + if [[ $new_head == "$head_sha" ]]; then + finish rocket ":white_check_mark: All ${#commits[@]} commit(s) already carry the \`$SIGNOFF_KEY\` of @$COMMENTER; nothing to do." + fi + + # Self-check: /signoff kept every tree and author line. + if [[ $COMMAND == signoff ]]; then + local fmt='%T %an <%ae> %ad' + diff -u <(git log --reverse --format="$fmt" "$merge_base..$head_sha") \ + <(git log --reverse --format="$fmt" "$merge_base..$new_head") + fi + + if ! git push -q --force-with-lease="refs/heads/$head_ref:$head_sha" \ + "$SERVER_URL/$head_repo.git" "$new_head:refs/heads/$head_ref"; then + react confused + api -X POST "repos/$REPO/issues/$PR_NUMBER/comments" -f body=":x: @$COMMENTER \`/$COMMAND\`: pushing the rewritten branch failed. Either the branch changed meanwhile, or the token may not push to it (for example, commits touching \`.github/workflows\` need the App's workflows permission). See the [workflow run]($SERVER_URL/$REPO/actions/runs/$GITHUB_RUN_ID)." > /dev/null + echo "replied=true" >> "$GITHUB_OUTPUT" + exit 1 + fi + + local count summary note="" + count=$(git rev-list --count "$onto..$new_head") + summary="$(short "$head_sha") → $(short "$new_head")" + [[ $HAVE_APP == true ]] || + note=" This repository has no App token configured, so workflows won't run on the new head until the next push." + if [[ $COMMAND == signoff ]]; then + finish rocket ":white_check_mark: Added the \`$SIGNOFF_KEY\` of @$COMMENTER to the PR's $count commit(s), content unchanged: $summary.$note" + fi + local dropped=$(( ${#commits[@]} - count )) + (( dropped == 0 )) || note=" $dropped commit(s) became empty and were dropped.$note" + finish rocket ":white_check_mark: Rebased $count commit(s) onto the target branch (at $(short "$base_tip")) and added the \`$SIGNOFF_KEY\` of @$COMMENTER: $summary.$note" +} + +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + main +fi diff --git a/pr-signoff/test.sh b/pr-signoff/test.sh new file mode 100755 index 0000000..dcfc522 --- /dev/null +++ b/pr-signoff/test.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# Offline tests of pr-signoff.sh: command parsing, role mapping, and the +# commit rewrite in a scratch repository. No network. +# pr-signoff/test.sh +set -euo pipefail +shopt -s inherit_errexit + +# shellcheck source=pr-signoff/pr-signoff.sh +source "$(dirname "$0")/pr-signoff.sh" + +failures=0 +fail() { echo "FAIL: $*" 1>&2; failures=$((failures + 1)); } +expect() { [[ $2 == "$3" ]] || fail "$1: expected '$3', got '$2'"; } + +# COMMENT (\n-escaped) => "COMMAND ARGS" or "-" for not a command. +while IFS='|' read -r body expected; do + [[ -n $body ]] || continue + body=$(printf '%b' "$body") + got=- + if parse_command "$body"; then got="$COMMAND ${ARGS[*]}"; fi + expect "parse '$body'" "${got% }" "$expected" +done <<'EOF' +/signoff|signoff +/signoff 0123456789ab|signoff 0123456789ab +/rebase-signoff abc def |rebase-signoff abc def +/signoff\r\nthanks|signoff +/signoff\tabc|signoff abc +/signoffs|- +please /signoff|- + /signoff|- +/SIGNOFF|- +hello\n/signoff|- +EOF + +# role_name|permission => effective role +while IFS='|' read -r role_name permission expected; do + [[ -n $expected ]] || continue + json=$(jq -nc --arg r "$role_name" --arg p "$permission" \ + '{permission: $p} + (if $r == "null" then {} else {role_name: $r} end)') + expect "role $role_name/$permission" "$(effective_role "$json")" "$expected" +done <<'EOF' +admin|admin|admin +maintain|write|maintain +maintainer|write|maintain +triage|read|triage +read|read|read +|write|write +null|read|read +Security Champions|write|write +Custom admin-ish|admin|none +null|none|none +EOF +expect "role from {}" "$(effective_role '{}')" none + +# ROLE REQUIRED => allowed? +while read -r role required expected; do + [[ -n $role ]] || continue + got=no + if role_allows "$role" "$required"; then got=yes; fi + expect "$role allows $required" "$got" "$expected" +done <<'EOF' +none read no +read read yes +read triage no +triage triage yes +triage write no +write write yes +maintain write yes +admin write yes +EOF + +# SHA argument => valid? +while read -r sha expected; do + [[ -n $sha ]] || continue + got=no + if [[ $sha =~ $SHA_RE ]]; then got=yes; fi + expect "sha $sha" "$got" "$expected" +done <<'EOF' +0123456789a no +0123456789ab yes +0123456789abcdef0123456789abcdef01234567 yes +0123456789abcdef0123456789abcdef012345678 no +0123456789aB no +EOF + +# COMMAND FORK => required role +while read -r command fork expected; do + [[ -n $command ]] || continue + expect "required $command fork=$fork" "$(required_role "$command" "$fork")" "$expected" +done <<'EOF' +signoff false triage +signoff true write +rebase-signoff false write +rebase-signoff true write +EOF + +# --- rewrite, in a scratch repository ----------------------------------------- +export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +git init -q -b main "$work" +cd "$work" +export GIT_AUTHOR_NAME="A. Author." GIT_AUTHOR_EMAIL=author@example.com GIT_AUTHOR_DATE="1700000000 +0200" +export GIT_COMMITTER_NAME=Committer GIT_COMMITTER_EMAIL=committer@example.com GIT_COMMITTER_DATE="1700000000 +0200" +TRAILER="Signed-off-by: Maintainer " +COMMITTER="Maintainer 1800000000 +0000" +commit() { # commit FILE CONTENT [git commit args]: prints the new commit + printf '%s\n' "$2" > "$1" + git add "$1" + git commit -q --cleanup=verbatim "${@:3}" + git rev-parse HEAD +} +base=$(commit f base -m base) +git switch -q -c pr +# A message git's usual cleanup would change, one in Latin-1, and one that +# already has the sign-off. +c1=$(commit a 1 -m $'Keep trailing space \n\n\n# not a comment\n---\nbody ') +c2=$(printf 'Caf\xe9\n' | git -c i18n.commitEncoding=ISO-8859-1 commit -q --allow-empty -F - && git rev-parse HEAD) +c3=$(commit b 3 -m "Signed" -m "$TRAILER") + +new=$(rewrite signoff "$base" "$c1" "$c2" "$c3") +mapfile -t olds < <(git rev-list --reverse "$base..$c3") +mapfile -t news < <(git rev-list --reverse "$base..$new") +expect "signoff commit count" "${#news[@]}" 3 +for i in 0 1 2; do + o=${olds[$i]} n=${news[$i]} + expect "tree $i" "$(git rev-parse "$n^{tree}")" "$(git rev-parse "$o^{tree}")" + expect "author $i" "$(git cat-file commit "$n" | grep '^author ')" "$(git cat-file commit "$o" | grep '^author ')" + expect "committer $i" "$(git cat-file commit "$n" | sed -n 's/^committer //p')" "$COMMITTER" + # The message starts with the original's exact bytes, and ends with + # the trailer. + size=$(git cat-file commit "$o" | sed '1,/^$/d' | wc -c) + expect "message $i" "$(git cat-file commit "$n" | sed '1,/^$/d' | head -c "$size" | od -c)" \ + "$(git cat-file commit "$o" | sed '1,/^$/d' | od -c)" + expect "trailer $i" "$(git cat-file commit "$n" | tail -n1)" "$TRAILER" +done +expect "encoding kept" "$(git cat-file commit "${news[1]}" | sed -n 's/^encoding //p')" ISO-8859-1 +expect "one trailer on the signed commit" "$(git cat-file commit "${news[2]}" | grep -c "^$TRAILER$")" 1 +expect "rerun is a no-op" "$(rewrite signoff "$base" "${news[@]}")" "$new" + +# rebase-signoff: onto a moved base, a commit that becomes empty is dropped +# (one that was empty to begin with is kept), and a conflict returns 2. +git switch -q main +moved=$(commit a 1 -m "same change as c1 on main") +new=$(rewrite rebase-signoff "$moved" "$c1" "$c2" "$c3") +expect "rebased onto the moved base" "$(git merge-base "$moved" "$new")" "$moved" +expect "emptied c1 dropped, empty c2 kept" "$(git rev-list --count "$moved..$new")" 2 +expect "rebased content" "$(git show "$new:b")" 3 +conflict=$(commit b other -m "conflicting change on main") +rc=0 +rewrite rebase-signoff "$conflict" "$c1" "$c2" "$c3" > /dev/null || rc=$? +expect "conflict" "$rc" 2 + +(( failures == 0 )) || { echo "$failures failure(s)" 1>&2; exit 1; } +echo "ok: pr-signoff tests passed"