Skip to content

Commit f29701f

Browse files
committed
Merge origin/main into asr
Brings in the self-hosted runner GPU test workflow (#115): comment-driven test runs gated on admin/maintainer role, with results posted as sticky PR comments. Conflict in .github/workflows/gpu-tests.yaml resolved in favor of main. The asr side's setup-uv + 'uv sync --extra audio' step is obsolete under main's design, where the helper scripts and environment are baked into the runner image at /opt/gsw rather than installed per run. Audio coverage is preserved as a first-class suite scope selected by marker. Signed-off-by: aviv ron <rona@il.ibm.com>
2 parents 7191cb3 + 91bf799 commit f29701f

4 files changed

Lines changed: 664 additions & 11 deletions

File tree

.github/scripts/check_role.sh

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
#!/usr/bin/env bash
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Gate: verify an actor holds the Maintain or Admin role on the repo under test.
5+
#
6+
# This is the single source of truth for "who may launch GPU tests". It runs in
7+
# two places:
8+
# 1. Baked into the runner image at /opt/gsw/check_role.sh, called as the
9+
# first step of workflow/gpu-tests.yaml. This is the AUTHORITATIVE gate: it
10+
# covers every entry point, including a direct workflow_dispatch from the
11+
# Actions tab (which needs only *write* access, so it would otherwise
12+
# bypass the /gpu-test comment check entirely).
13+
# 2. Checked into the repository and used by the /gpu-test
14+
# comment workflow, which runs on a GitHub-hosted runner and therefore
15+
# cannot reach /opt/gsw. That copy is a fast-fail UX nicety only.
16+
#
17+
# Living in the image is what makes (1) trustworthy: the gate cannot be edited
18+
# by a pull request, only by rebuilding and redeploying the runner image.
19+
#
20+
# The default GITHUB_TOKEN bot is allowed through: when gpu-tests.yaml is
21+
# dispatched by the comment workflow, github.actor is github-actions[bot], and
22+
# that path was already role-checked upstream.
23+
#
24+
# Usage: check_role.sh <actor-login>
25+
# Env: GH_TOKEN token with repo read access
26+
# GITHUB_REPOSITORY owner/repo to check the role against
27+
# Exit: 0 authorized, 1 not authorized (reason on stderr).
28+
set -euo pipefail
29+
30+
ACTOR="${1:?usage: check_role.sh <actor-login>}"
31+
REPO="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY must be set}"
32+
33+
if [[ "$ACTOR" == "github-actions[bot]" ]]; then
34+
echo "actor=$ACTOR is the workflow bot (already gated upstream) — authorized"
35+
exit 0
36+
fi
37+
38+
# role_name is the granular role: admin / maintain / write / triage / read.
39+
# author_association cannot distinguish maintain from write, so it is unusable
40+
# for this check.
41+
ROLE="$(gh api "repos/${REPO}/collaborators/${ACTOR}/permission" --jq '.role_name')"
42+
43+
if [[ "$ROLE" == "admin" || "$ROLE" == "maintain" ]]; then
44+
echo "actor=$ACTOR role=$ROLE — authorized"
45+
exit 0
46+
fi
47+
48+
echo "actor=$ACTOR role=${ROLE:-none} — NOT authorized (requires maintain or admin)" >&2
49+
exit 1
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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})"
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
#
3+
# Slash-command launcher for /gpu-test, /gpu-test-short and /gpu-test-dev.
4+
#
5+
# The `if:` below is a cheap prefix prefilter, NOT the command parser: it lets any
6+
# /gpu-test* comment start this job, and gpu_test_command.sh then matches the
7+
# command exactly and declines anything else. Keeping the list in one place means
8+
# adding a command does not need an edit here.
9+
#
10+
# Deployed to granite-switch as .github/workflows/gpu-test-command.yaml, together
11+
# with .github/scripts/check_role.sh and .github/scripts/gpu_test_command.sh.
12+
#
13+
# WHY THOSE TWO SCRIPTS ARE CHECKED IN RATHER THAN BAKED INTO THE RUNNER IMAGE:
14+
# this job runs on a GitHub-HOSTED runner, which cannot read the image's scripts,
15+
# so they must come from the repository. Both contain only GitHub API calls.
16+
#
17+
# This is a convenience entry point, NOT the security boundary: gpu-tests.yaml
18+
# re-checks the role via /opt/gsw/check_role.sh, which a pull request cannot edit.
19+
#
20+
# NOTE: issue_comment workflows only fire when the file is on the DEFAULT branch.
21+
# It will not react to comments until merged to main.
22+
23+
name: GPU Test Command
24+
25+
on:
26+
issue_comment:
27+
types: [created]
28+
29+
permissions:
30+
actions: write # dispatch gpu-tests.yaml
31+
pull-requests: write # react to the comment and post feedback
32+
contents: read
33+
34+
jobs:
35+
dispatch:
36+
name: Dispatch GPU tests
37+
runs-on: ubuntu-latest
38+
# Only for `/gpu-test` comments on a pull request (not plain issues).
39+
if: >-
40+
github.event.issue.pull_request &&
41+
startsWith(github.event.comment.body, '/gpu-test')
42+
steps:
43+
- name: Checkout scripts (trusted default branch)
44+
uses: actions/checkout@v4
45+
46+
- name: Handle /gpu-test command
47+
env:
48+
GH_TOKEN: ${{ github.token }}
49+
GITHUB_REPOSITORY: ${{ github.repository }}
50+
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
51+
SCRIPT_DIR: ${{ github.workspace }}/.github/scripts
52+
# The test families live in this file's `suite` input and nowhere else.
53+
# The handler reads them out of it to validate the command and to build
54+
# its decline message, so adding a family is a one-file change there.
55+
WORKFLOW_FILE: ${{ github.workspace }}/.github/workflows/gpu-tests.yaml
56+
# GitHub-controlled values pass through env, never interpolated into the
57+
# script body — prevents shell injection via a crafted login.
58+
ACTOR: ${{ github.event.comment.user.login }}
59+
PR_NUMBER: ${{ github.event.issue.number }}
60+
COMMENT_ID: ${{ github.event.comment.id }}
61+
# The body decides WHICH scope runs (/gpu-test, -short or -dev). Entirely
62+
# attacker-controlled text, so it follows the same rule as the login: env
63+
# var, then a quoted positional arg, never an Actions expression inside the
64+
# run block.
65+
BODY: ${{ github.event.comment.body }}
66+
run: .github/scripts/gpu_test_command.sh "$ACTOR" "$PR_NUMBER" "$COMMENT_ID" "$BODY"

0 commit comments

Comments
 (0)