Skip to content
Merged
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
114 changes: 84 additions & 30 deletions .github/actions/comment-triggered-check/README.md

Large diffs are not rendered by default.

51 changes: 44 additions & 7 deletions .github/actions/comment-triggered-check/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@ description: |
command should run, resolves the pull request identity the event does not
carry, and owns the check-run lifecycle.

Two modes. `start` parses the comment, authorizes the commenter, resolves the
Three modes. `start` parses the comment, authorizes the commenter, resolves the
head SHA and base ref, and opens the check-run. `finish` resolves the terminal
outcome with a fail-closed matrix and completes it. Repeated commands are
outcome with a fail-closed matrix and completes it. `link` records the run a
check was dispatched to in the command's reply. Repeated commands are
deduplicated by the caller's concurrency group, not here.

With `reply: true`, start answers the command comment with one reply that
lists its runs, and link and finish edit that reply as the runs progress.
inputs:
mode:
description: 'Either "start" or "finish".'
description: 'One of "start", "finish" or "link".'
required: true
command:
description: 'Command word that must open the comment, on its own first line.'
Expand Down Expand Up @@ -66,16 +70,32 @@ inputs:
description: 'start mode. Target represented by this invocation. Setting it enables --target parsing; a different explicit target returns target-not-selected without opening a check.'
required: false
default: ''
reply:
description: 'start mode. Set to true to react to the command comment and answer it with one reply that links each run and shows its result. Needs comment-id, and pull-requests: write for github-token in every job that runs start, link or finish. Disabled by default.'
required: false
default: 'false'
comment-id:
description: 'start mode. Id of the command comment, which the reply answers. Pass the github.event.comment.id context.'
required: false
default: ''
run-label:
description: 'start mode. Short name for this invocation''s runs in the reply, such as "Pro". Defaults to check-name-prefix.'
required: false
default: ''
run-url:
description: 'link mode. URL of the run dispatched for check-run-id. Stored on the check, so link edits the check: pass the same app inputs as start when the app opened it.'
required: false
default: ''
run-id:
description: 'start mode. Used to build the check-run details link. Pass the github.run_id context.'
required: false
default: ''
server-url:
description: 'start mode. Base URL used to build the check-run details link. Pass the github.server_url context.'
description: 'Base URL for the check-run details link and the reply''s links. Pass the github.server_url context.'
required: false
default: 'https://github.com'
check-run-id:
description: 'finish mode. The id returned by start mode. Empty is not an error; it means no check was opened.'
description: 'finish and link modes. The id returned by start mode. Empty is not an error; it means no check was opened.'
required: false
default: ''
report-conclusion:
Expand Down Expand Up @@ -176,12 +196,12 @@ runs:
using: composite
steps:
- name: Reject an unknown mode
if: inputs.mode != 'start' && inputs.mode != 'finish'
if: inputs.mode != 'start' && inputs.mode != 'finish' && inputs.mode != 'link'
shell: bash
env:
INPUT_MODE: ${{ inputs.mode }}
run: |
echo "::error::mode must be 'start' or 'finish', got '${INPUT_MODE}'"
echo "::error::mode must be 'start', 'finish' or 'link', got '${INPUT_MODE}'"
exit 1

# Only the identity that opened a check-run can complete it; start and
Expand Down Expand Up @@ -219,6 +239,9 @@ runs:
INPUT_SERVER_URL: ${{ inputs.server-url }}
INPUT_APP_TOKEN: ${{ steps.app-token.outputs.token }}
INPUT_FINISH_WORKFLOW: ${{ inputs.finish-workflow }}
INPUT_REPLY: ${{ inputs.reply }}
INPUT_COMMENT_ID: ${{ inputs.comment-id }}
INPUT_RUN_LABEL: ${{ inputs.run-label }}
GH_TOKEN: ${{ inputs.github-token }}
run: ${{ github.action_path }}/src/start.sh

Expand All @@ -234,9 +257,23 @@ runs:
INPUT_SUITE_RESULT: ${{ inputs.suite-result }}
INPUT_SUMMARY: ${{ inputs.summary }}
INPUT_DETAILS_URL: ${{ inputs.details-url }}
INPUT_SERVER_URL: ${{ inputs.server-url }}
INPUT_APP_TOKEN: ${{ steps.app-token.outputs.token }}
GH_TOKEN: ${{ inputs.github-token }}
run: ${{ github.action_path }}/src/finish.sh

- name: Link
id: link
if: inputs.mode == 'link'
shell: bash
env:
INPUT_CHECK_RUN_ID: ${{ inputs.check-run-id }}
INPUT_RUN_URL: ${{ inputs.run-url }}
INPUT_REPO: ${{ inputs.repo }}
INPUT_SERVER_URL: ${{ inputs.server-url }}
INPUT_APP_TOKEN: ${{ steps.app-token.outputs.token }}
GH_TOKEN: ${{ inputs.github-token }}
run: ${{ github.action_path }}/src/link.sh
branding:
icon: 'play-circle'
color: 'orange'
9 changes: 9 additions & 0 deletions .github/actions/comment-triggered-check/src/finish.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ set -euo pipefail

# shellcheck source=.github/actions/comment-triggered-check/src/lib.sh
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
# shellcheck source=.github/actions/comment-triggered-check/src/reply.sh
source "$(dirname "${BASH_SOURCE[0]}")/reply.sh"

check_run_id="${INPUT_CHECK_RUN_ID:-}"
repo="${INPUT_REPO:?INPUT_REPO required}"
Expand Down Expand Up @@ -199,5 +201,12 @@ republish_if_hidden() {

republish_if_hidden

# A command started with a reply stores its id on the check. Redrawing it needs
# pull-requests: write; without that the reply keeps its last state.
ref="$(check_reply_ref "$repo" "$check_run_id")"
if [[ -n "$ref" ]]; then
reply_refresh "$repo" "$(jq -r .reply <<< "$ref")" "$(jq -r .sha <<< "$ref")"
fi

echo "::notice::published ${conclusion} to check-run ${check_run_id}"
emit "conclusion" "$conclusion"
122 changes: 122 additions & 0 deletions .github/actions/comment-triggered-check/src/lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,128 @@ refusal_details() {
esac
}

# --- Reply comment ------------------------------------------------------------
# One reply per command comment, drawn entirely from the command's check-runs.
# Each check's external_id is a small JSON object: the reply id (r), the
# workflow attempt that opened it (a), its row label (l) and, once linked, its
# run URL (u). The reply's own state holds only what never changes: the command
# and the commit. So any writer that redraws after another's change draws it,
# and nothing a stale writer puts back can hide a row or a link for good. The
# state is base64 so user text can never close the HTML comment around it.

REPLY_MARKER_PREFIX="<!-- comment-triggered-check:reply:"
REPLY_STATE_PREFIX="<!-- comment-triggered-check:state "
# A run link is drawn only when it matches this; link checks it with jq too, so
# both use the same rule.
RUN_URL_PATTERN='^https://[^\s()\[\]<>]+$'

# reply_marker <comment-id> — first line of the reply for that command comment.
reply_marker() {
printf '%s%s -->' "$REPLY_MARKER_PREFIX" "${1-}"
}

# reply_state_decode <body> — the state JSON from a reply body; empty and
# non-zero when the body has no readable state.
reply_state_decode() {
local line encoded json
line="$(grep -F -- "$REPLY_STATE_PREFIX" <<< "${1-}" | head -n1)" || return 1
encoded="${line#*"$REPLY_STATE_PREFIX"}"
encoded="${encoded%% -->*}"
json="$(printf '%s' "$encoded" | base64 -d 2>/dev/null)" || return 1
jq -e 'type == "object" and (.command | type) == "string"' <<< "$json" >/dev/null 2>&1 || return 1
jq -c . <<< "$json"
}

# is_run_url <url> — true when the URL may be drawn as a run link.
is_run_url() {
jq -e -n --arg u "${1-}" --arg p "$RUN_URL_PATTERN" '$u | test($p)' >/dev/null 2>&1
}

# run_label <label> <repeat> <run-index> <run-count> — row label for one run.
run_label() {
local label="${1-}" repeat="${2-}" k="${3:-1}" n="${4:-1}"
if [[ -n "$repeat" ]]; then
printf '%s, repeat %s' "$label" "$repeat"
elif (( n > 1 )); then
printf '%s, run %s of %s' "$label" "$k" "$n"
else
printf '%s' "$label"
fi
}

# reply_rows <reply-id> <check-runs-json> — the checks that belong to the reply,
# newest attempt only, oldest first. Input is any array of check-runs.
reply_rows() {
jq -c --argjson r "${1:-0}" '
[.[] | select(type == "object")
| . + {ext: ((.external_id // "") | try fromjson catch null)}
| select((.ext | type) == "object" and .ext.r == $r)]
| (map(.ext.a // 0) | max // 0) as $latest
| map(select((.ext.a // 0) == $latest))
| unique_by(.id)
| map({id, status, conclusion, head_sha, label: (.ext.l // "run"), url: (.ext.u // "")})
' <<< "${2:-[]}"
}

# render_reply <state-json> <rows-json> <server-url> <repo>
# The reply body. rows-json comes from reply_rows. No rows means the command is
# still opening its checks, so the headline stays neutral: the first version of
# a comment is the one notification emails carry.
render_reply() {
local state="${1-}" rows="${2:-[]}" server="${3-}" repo="${4-}" encoded
encoded="$(printf '%s' "$state" | jq -c . | base64 | tr -d '\n')"
jq -r -n \
--argjson state "$state" \
--argjson rows "$rows" \
--arg base "${server}/${repo}" \
--arg marker "$(reply_marker "$(jq -r '.comment_id' <<< "$state")")" \
--arg encoded "$encoded" \
--arg state_prefix "$REPLY_STATE_PREFIX" \
--arg url_pattern "$RUN_URL_PATTERN" '
def bad: . as $c | ["failure", "timed_out", "cancelled", "action_required", "startup_failure", "stale"] | index($c) != null;
# CommonMark closes a code span only on a backtick run of the same length,
# so a fence one longer than the longest run in the text cannot be closed.
def code:
(if length > 200 then .[0:200] + "…" else . end) as $t
| ([$t | scan("`+") | length] | max // 0) as $longest
| ([range($longest + 1)] | map("`") | join("")) as $fence
| if $longest == 0 then $fence + $t + $fence else $fence + " " + $t + " " + $fence end;
def outcome:
{"success": "passed", "failure": "failed", "neutral": "nothing to run",
"timed_out": "timed out", "cancelled": "cancelled", "skipped": "skipped"}[.] // .;
([$rows[] | .head_sha // empty][0] // $state.sha // "" | .[0:7]) as $sha
| ($rows | map(.status == "completed") | all) as $done
| ($rows | length) as $n
| (if $sha == "" then "" else " on `" + $sha + "`" end) as $on
| ($state.command | code) as $cmd
| (if $n == 0 then
":rocket: " + $cmd + " is starting" + $on + "."
elif ($done | not) then
":rocket: " + $cmd + " started " + ($n | tostring) + (if $n == 1 then " run" else " runs" end)
+ $on + ". This comment updates when " + (if $n == 1 then "it finishes." else "they finish." end)
elif ($rows | map(.conclusion | bad) | any) then
":x: " + $cmd + " finished" + $on + "."
else
":white_check_mark: " + $cmd + " finished" + $on + "."
end) as $headline
| [$rows[]
| .label as $label
| ((.url // "") | if test($url_pattern) then . else "" end) as $url
| ($base + "/runs/" + (.id | tostring)) as $check_url
| if .status != "completed" then
if $url != "" then "- " + $label + ": running, [watch the run](" + $url + ")"
else "- " + $label + ": starting, [view the check](" + $check_url + ")" end
elif $url == "" and .conclusion == "failure" then
"- " + $label + ": did not start, [see why](" + $check_url + ")"
elif $url == "" then
"- " + $label + ": " + (.conclusion | outcome) + ", [view the check](" + $check_url + ")"
else
"- " + $label + ": " + (.conclusion | outcome) + ", [view the run](" + $url + ")"
end] as $lines
| ([$marker, $state_prefix + $encoded + " -->", $headline] + (if $n == 0 then [] else [""] + $lines end)) | join("\n")
'
}

# emit <name> <value> — write a step output and echo it for the log.
# dispatch_ref_for <is-fork> <head-ref> <base-ref> — the ref whose workflow a
# caller dispatches: the target branch for forks, the pull request's otherwise.
Expand Down
50 changes: 50 additions & 0 deletions .github/actions/comment-triggered-check/src/link.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# link mode: store the run a check-run was dispatched to on that check, in
# its external_id, and redraw the reply. Best effort, never fails the caller.
#
# The link lives on the check, not in the reply, so a job that overwrites the
# reply with an older view still draws this link on its read-back pass.
#
# Inputs (env):
# INPUT_CHECK_RUN_ID check-run opened by start mode
# INPUT_RUN_URL URL of the run that works on that check
# INPUT_REPO owner/name
# INPUT_SERVER_URL github.server_url
# GH_TOKEN token with pull-requests: write, and checks: write
# when it opened the check
# INPUT_APP_TOKEN optional GitHub App token, used when the app opened it
set -euo pipefail

# shellcheck source=.github/actions/comment-triggered-check/src/lib.sh
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
# shellcheck source=.github/actions/comment-triggered-check/src/reply.sh
source "$(dirname "${BASH_SOURCE[0]}")/reply.sh"

check_run_id="${INPUT_CHECK_RUN_ID:-}"
run_url="${INPUT_RUN_URL:-}"
repo="${INPUT_REPO:?INPUT_REPO required}"

if [[ ! "$check_run_id" =~ ^[0-9]+$ ]]; then
echo "no check-run to link"
exit 0
fi
if ! is_run_url "$run_url"; then
echo "::warning::run-url is not a plain https URL; not linking check-run ${check_run_id}"
exit 0
fi

ref="$(check_reply_ref "$repo" "$check_run_id")"
if [[ -z "$ref" ]]; then
echo "check-run ${check_run_id} has no reply to update"
exit 0
fi

ext="$(jq -c --arg u "$run_url" '.ext + {u: $u}' <<< "$ref")"
if ! GH_TOKEN="$(check_owner_token "$repo" "$check_run_id")" gh api --method PATCH \
"repos/${repo}/check-runs/${check_run_id}" -f "external_id=${ext}" >/dev/null 2>&1; then
echo "::warning::could not store the run link on check-run ${check_run_id}"
exit 0
fi

reply_refresh "$repo" "$(jq -r .reply <<< "$ref")" "$(jq -r .sha <<< "$ref")"
echo "::notice::linked check-run ${check_run_id} to ${run_url}"
Loading
Loading