|
| 1 | +#!/usr/bin/env bash |
| 2 | +# SPDX-License-Identifier: Apache-2.0 |
| 3 | +# |
| 4 | +# Handle a /gpu-test* PR comment: verify the commenter holds Maintain/Admin, work |
| 5 | +# out which test scope was asked for, then dispatch gpu-tests.yaml against the PR's |
| 6 | +# head commit. On rejection, react 👎 and reply naming the requirement. |
| 7 | +# |
| 8 | +# THIS SCRIPT DOES NOT KNOW THE TEST FAMILIES. It derives the suite name from the |
| 9 | +# command instead: `/gpu-test` -> full, `/gpu-test-<x>` -> x. So the list lives in |
| 10 | +# exactly one place, gpu-tests.yaml's `suite` input, and adding a family is a |
| 11 | +# one-file change there rather than an edit here that is easy to forget -- the old |
| 12 | +# shape had a hardcoded case, and forgetting it meant a family that existed in the |
| 13 | +# workflow, the mapping and the docs was still told "not a command". |
| 14 | +# |
| 15 | +# `/gpu-test-<anything>` is NOT accepted. Two layers reject an unknown name: |
| 16 | +# |
| 17 | +# 1. This script reads the `options:` list out of the checked-out workflow (see |
| 18 | +# WORKFLOW_FILE) and declines locally -- in seconds, on a hosted runner, with |
| 19 | +# a reply listing the families READ FROM THAT LIST so it cannot go stale. |
| 20 | +# 2. If that read fails -- someone reformats `options:` into a block list -- the |
| 21 | +# dispatch goes ahead and GitHub's own `type: choice` validation rejects it |
| 22 | +# with a 422, which is caught below. So a reformat costs the nice message, |
| 23 | +# never the enforcement. |
| 24 | +# |
| 25 | +# Deliberately NOT fully dynamic. With no `options:` the dispatch would succeed and |
| 26 | +# the failure would land as a red GPU Tests run, having consumed a runner slot and a |
| 27 | +# queue wait, for a typo. There is no upside either: a name with no `options:` entry |
| 28 | +# has no mapping arm to run. |
| 29 | +# |
| 30 | +# The suite NAME is dispatched, never a path list: gpu-tests.yaml owns the mapping. |
| 31 | +# |
| 32 | +# Deployed to granite-switch as .github/scripts/gpu_test_command.sh. |
| 33 | +# It runs on a GitHub-hosted runner (no /opt/gsw), which is why it is checked in |
| 34 | +# rather than being baked into the runner image. See |
| 35 | +# gpu-test-command.yaml for the full rationale. |
| 36 | +# |
| 37 | +# This check is fast-fail UX. The authoritative gate is /opt/gsw/check_role.sh |
| 38 | +# inside gpu-tests.yaml, which also covers direct workflow_dispatch. |
| 39 | +# |
| 40 | +# All GitHub-controlled values arrive as positional args from quoted env in the |
| 41 | +# workflow — never interpolated into this script — so a crafted login cannot |
| 42 | +# inject shell. |
| 43 | +# |
| 44 | +# Usage: gpu_test_command.sh <actor-login> <pr-number> <comment-id> <comment-body> |
| 45 | +# Env: GH_TOKEN, GITHUB_REPOSITORY, DEFAULT_BRANCH, SCRIPT_DIR |
| 46 | +# WORKFLOW_FILE optional path to the checked-out gpu-tests.yaml. Enables the |
| 47 | +# local family check; without it layer 2 above still applies. |
| 48 | +set -euo pipefail |
| 49 | + |
| 50 | +ACTOR="${1:?usage: gpu_test_command.sh <actor-login> <pr-number> <comment-id> <comment-body>}" |
| 51 | +PR_NUMBER="${2:?missing pr number}" |
| 52 | +COMMENT_ID="${3:?missing comment id}" |
| 53 | +# May legitimately be empty or multi-line, so no :? guard. |
| 54 | +BODY="${4:-}" |
| 55 | + |
| 56 | +REPO="${GITHUB_REPOSITORY:?}" |
| 57 | +DEFAULT_BRANCH="${DEFAULT_BRANCH:?}" |
| 58 | +SCRIPT_DIR="${SCRIPT_DIR:?}" |
| 59 | +WORKFLOW_FILE="${WORKFLOW_FILE:-}" |
| 60 | + |
| 61 | +react() { |
| 62 | + gh api -X POST "repos/${REPO}/issues/comments/${COMMENT_ID}/reactions" \ |
| 63 | + -f content="$1" >/dev/null |
| 64 | +} |
| 65 | + |
| 66 | +reply() { |
| 67 | + gh api -X POST "repos/${REPO}/issues/${PR_NUMBER}/comments" -f body="$1" >/dev/null |
| 68 | +} |
| 69 | + |
| 70 | +# check_role.sh exits non-zero (and prints the role) when not authorized. |
| 71 | +if ! ROLE_MSG="$("${SCRIPT_DIR}/check_role.sh" "$ACTOR" 2>&1)"; then |
| 72 | + react '-1' |
| 73 | + reply "@${ACTOR} the GPU test commands require the **Maintain** or **Admin** role. Not launching." |
| 74 | + echo "$ROLE_MSG" >&2 |
| 75 | + exit 1 |
| 76 | +fi |
| 77 | + |
| 78 | +# The families, read from the ONE place they are defined: the `options:` line of |
| 79 | +# the `suite` input in the checked-out gpu-tests.yaml. Space-separated, or empty if |
| 80 | +# the file is absent or the line is not in flow style -- in which case the dispatch |
| 81 | +# below is left to GitHub to validate. |
| 82 | +# |
| 83 | +# One awk, no pipe: splitting on [ and ] puts the list body in $2, and `exit` stops |
| 84 | +# at the first match. (A pipe into head would risk SIGPIPE under `pipefail`.) |
| 85 | +FAMILIES="" |
| 86 | +if [[ -n "$WORKFLOW_FILE" && -r "$WORKFLOW_FILE" ]]; then |
| 87 | + FAMILIES="$(awk -F'[][]' ' |
| 88 | + /^[[:space:]]*options:[[:space:]]*\[/ { gsub(/[ ,]+/, " ", $2); print $2; exit } |
| 89 | + ' "$WORKFLOW_FILE" 2>/dev/null || true)" |
| 90 | +fi |
| 91 | + |
| 92 | +# Render the families back as the commands a human types: `full` is the bare |
| 93 | +# /gpu-test, everything else is suffixed. Used only in the decline message. |
| 94 | +usage_list() { |
| 95 | + local f out="" |
| 96 | + for f in $FAMILIES; do |
| 97 | + if [[ "$f" == "full" ]]; then out="${out}\`/gpu-test\` "; else out="${out}\`/gpu-test-${f}\` "; fi |
| 98 | + done |
| 99 | + printf '%s' "$out" |
| 100 | +} |
| 101 | + |
| 102 | +# Exit 0 throughout: a mistyped command is user error, not a broken workflow, and a |
| 103 | +# red X on the launcher would send someone hunting a bug that isn't there. |
| 104 | +decline() { |
| 105 | + local msg="@${ACTOR} $1" |
| 106 | + # Built in steps rather than as one ${FAMILIES:+...} expansion: $'\n' inside that |
| 107 | + # is honoured by bash but not by every shell, and a message that silently prints |
| 108 | + # a literal $'\n\n' is not worth the saved line. |
| 109 | + if [[ -n "$FAMILIES" ]]; then |
| 110 | + msg="$msg"$'\n\n'"Available: $(usage_list)" |
| 111 | + fi |
| 112 | + react 'confused' |
| 113 | + reply "$msg" |
| 114 | + echo "declined: $2" >&2 |
| 115 | + exit 0 |
| 116 | +} |
| 117 | + |
| 118 | +# Which scope? First whitespace-delimited token of the FIRST line, so |
| 119 | +# "/gpu-test-dev please" works and a command followed by prose or a second |
| 120 | +# paragraph still parses. \r is stripped because GitHub sends CRLF line endings. |
| 121 | +CMD="$(printf '%s' "$BODY" | head -n1 | tr -d '\r' | awk '{print $1}')" |
| 122 | + |
| 123 | +# Derive rather than look up. Note `/gpu-testing` does NOT match /gpu-test-* (the |
| 124 | +# next character is `i`, not `-`), so the workflow's startsWith prefilter letting it |
| 125 | +# through does not make it a command. |
| 126 | +case "$CMD" in |
| 127 | + /gpu-test) SUITE="full" ;; |
| 128 | + /gpu-test-*) SUITE="${CMD#/gpu-test-}" ;; |
| 129 | + *) decline "\`${CMD}\` is not a GPU test command." "not a command: '$CMD'" ;; |
| 130 | +esac |
| 131 | + |
| 132 | +# The derived name comes from an attacker-controlled comment and ends up in an API |
| 133 | +# request, so it is constrained to a shape a family name could plausibly have before |
| 134 | +# it is used for anything. Also stops an empty `/gpu-test-` from being dispatched. |
| 135 | +if [[ ! "$SUITE" =~ ^[a-z0-9][a-z0-9-]{0,31}$ ]]; then |
| 136 | + decline "\`${CMD}\` is not a GPU test command." "malformed family name: '$SUITE'" |
| 137 | +fi |
| 138 | + |
| 139 | +# Layer 1: local check against the list, when it could be read. |
| 140 | +if [[ -n "$FAMILIES" ]]; then |
| 141 | + case " $FAMILIES " in |
| 142 | + *" $SUITE "*) : ;; |
| 143 | + *) decline "there is no \`${SUITE}\` test family." "unknown family: '$SUITE'" ;; |
| 144 | + esac |
| 145 | +fi |
| 146 | + |
| 147 | +SHA="$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.head.sha')" |
| 148 | + |
| 149 | +# Layer 2: GitHub's own `type: choice` validation. Only reachable when FAMILIES |
| 150 | +# could not be read, since layer 1 would have caught it otherwise. |
| 151 | +# |
| 152 | +# The reaction is posted AFTER a successful dispatch, not before: a 🚀 followed by |
| 153 | +# "no such family" reads as though something launched and then broke. |
| 154 | +if ! gh workflow run gpu-tests.yaml \ |
| 155 | + --ref "$DEFAULT_BRANCH" \ |
| 156 | + -f sha="$SHA" \ |
| 157 | + -f pr_number="$PR_NUMBER" \ |
| 158 | + -f suite="$SUITE" 2>/tmp/gh_dispatch_err; then |
| 159 | + echo "dispatch failed:" >&2 |
| 160 | + cat /tmp/gh_dispatch_err >&2 |
| 161 | + decline "could not launch \`${SUITE}\` — it is probably not a valid test family." \ |
| 162 | + "dispatch rejected for suite='$SUITE'" |
| 163 | +fi |
| 164 | + |
| 165 | +react 'rocket' |
| 166 | + |
| 167 | +echo "Dispatched gpu-tests.yaml (suite=${SUITE}) for PR #${PR_NUMBER} at ${SHA} (by ${ACTOR})" |
0 commit comments