From 33931021a984289df96a5124b41f12a0e1d0af8a Mon Sep 17 00:00:00 2001 From: Caue Santos Date: Fri, 6 Nov 2026 11:08:52 -0600 Subject: [PATCH 1/5] feat(comment-triggered-check): reply to each command with its runs --- .../actions/comment-triggered-check/README.md | 95 ++++++--- .../comment-triggered-check/action.yml | 50 ++++- .../comment-triggered-check/src/finish.sh | 9 + .../comment-triggered-check/src/lib.sh | 94 +++++++++ .../comment-triggered-check/src/link.sh | 41 ++++ .../comment-triggered-check/src/reply.sh | 90 ++++++++ .../comment-triggered-check/src/start.sh | 64 +++++- .../comment-triggered-check/test/finish.bats | 40 ++++ .../comment-triggered-check/test/gh_mock.bash | 56 +++++ .../comment-triggered-check/test/reply.bats | 195 ++++++++++++++++++ .../comment-triggered-check/test/start.bats | 113 ++++++++++ 11 files changed, 810 insertions(+), 37 deletions(-) create mode 100755 .github/actions/comment-triggered-check/src/link.sh create mode 100755 .github/actions/comment-triggered-check/src/reply.sh create mode 100644 .github/actions/comment-triggered-check/test/reply.bats diff --git a/.github/actions/comment-triggered-check/README.md b/.github/actions/comment-triggered-check/README.md index 7ee3d14a..92d08472 100644 --- a/.github/actions/comment-triggered-check/README.md +++ b/.github/actions/comment-triggered-check/README.md @@ -126,7 +126,7 @@ read-only collaborator passes. That coarse check remains sufficient for a same-repository command. When `allow-forks` is enabled, the action performs the precise permission lookup and refuses anyone below write access. -## The two modes +## The three modes `start` parses the comment, authorizes the commenter, resolves the pull request, and opens the check-run. A same-repository request takes two API calls. An @@ -137,6 +137,9 @@ command takes none. id from `start` and the raw job results; it computes the conclusion so the matrix lives in a tested script rather than in workflow YAML. +`link` records the run a check-run was dispatched to, for the optional reply +below. It needs only the check-run id and the run URL. + ## The outcome matrix fails closed | Condition | Conclusion | @@ -249,6 +252,32 @@ repeat. A missing or non-numeric value reports `malformed-repeat` or `malformed-runs`, a value outside the range `repeat-out-of-range` or `runs-out-of-range`. +## Optional reply + +Set `reply: "true"` and pass `comment-id` to answer every command with one +reply that links its runs. `start` reacts with :eyes:, posts the reply, and +stores the reply's id on each check-run as its `external_id`. Pass `run-label` +to name the rows, such as `Pro`; `--runs` and `--repeat` add `run k of N` or +`repeat N`. + +```text +:rocket: `/test-e2e snapshots` started 2 runs on `abc1234`. This comment updates when they finish. + +- Pro: running, [watch the run](...) +- OSS: passed, [view the run](...) +``` + +After each dispatch, call `link` with the check-run id and the run URL that +`gh workflow run` prints. `finish` then redraws the reply with the outcome. +The reply is edited in place, so it notifies nobody, and each new command gets +its own reply so older runs stay findable. A re-run of the workflow reuses the +reply and starts its rows over. + +The reply is a view of the check-runs. Every writer redraws all rows from the +checks' current state, reads the reply back, and writes again if another run +changed it in between. A reply that cannot be written only warns; it never +fails `start`, `link` or `finish`. + ## Permissions The calling job needs `checks: write` to create and complete the check-run, and @@ -257,38 +286,46 @@ the commenter's repository permission through repository metadata. With the app inputs, `start` also reads `finish-workflow`, which needs `contents: read`; without it the check opens with `github-token`. +With `reply`, every job that runs `start`, `link` or `finish` needs +`pull-requests: write` for `github-token`, to post, react and edit. A finish job +without it keeps the reply at its last state. + ## Inputs -| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION | -|--------------------|--------|----------|------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| allow-forks | string | false | `"false"` | start mode. Allow fork pull requests
only when the commenter has write,
maintain, or admin repository permission. Disabled
by default. | -| allowed-targets | string | false | `"pro oss"` | start mode. Whitespace-separated target values accepted
when target-name is set. Each value
needs a matching action invocation. | -| app-client-id | string | false | | Optional client ID of a GitHub
App with checks: write. With app-private-key,
the check-run gets its own check
suite. Pass the same pair to
start and finish. Empty keeps github-token. | -| app-private-key | string | false | | Optional private key for app-client-id. | -| author-association | string | false | | start mode. How the commenter relates
to the repository. Pass the github.event.comment.author_association
context. OWNER, MEMBER and COLLABORATOR may
run the command; anything else, including
empty, may not. | -| build-result | string | false | | finish mode. Result of the build
job. Pass the matching needs result. | -| check-name-prefix | string | false | `"e2e"` | start mode. Prefix for the check-run
name; the filter and optional target
and focus are appended. | -| check-run-id | string | false | | finish mode. The id returned by
start mode. Empty is not an
error; it means no check was
opened. | -| command | string | false | `"/test-e2e"` | Command word that must open the
comment, on its own first line. | -| comment-author | string | false | | start mode. Login of the commenter.
Pass the github.event.comment.user.login context. | -| comment-body | string | false | | start mode. The comment text. Pass
the github.event.comment.body context. | -| details-url | string | false | | finish mode. Link target for the
completed check-run. | -| finish-workflow | string | false | `"e2e-ginkgo.yaml"` | start mode. Workflow file that runs
finish. The app opens the check
only if that file passes both
app inputs to finish. | -| github-token | string | false | `"${{ github.token }}"` | Token for gh. The calling job
must grant checks: write and pull-requests:
read. | -| mode | string | true | | Either "start" or "finish". | -| parse-focus | string | false | `"false"` | start mode. Set to true to
split an optional trailing --focus expression
from the filter. Disabled by default
for existing consumers. | -| parse-repeat | string | false | `"false"` | start mode. Set to true to
accept `--repeat N` (1 to 10) before --focus: one
run whose suite repeats N extra
times. Disabled by default for existing
consumers. | -| parse-runs | string | false | `"false"` | start mode. Set to true to
accept `--runs N` (1 to 5) before --focus: N
independent runs, each with its own
check-run and concurrency key. Cannot be
combined with --repeat. Disabled by default
for existing consumers. | -| pr-number | string | false | | start mode. Pull request number. Pass
the github.event.issue.number context. | -| repo | string | false | `"${{ github.repository }}"` | Repository in owner/name form. | -| report-conclusion | string | false | | finish mode. What the test run
declared about itself, parsed from its
report. The only input that can
produce a neutral conclusion. | -| run-id | string | false | | start mode. Used to build the
check-run details link. Pass the github.run_id
context. | -| server-url | string | false | `"https://github.com"` | start mode. Base URL used to
build the check-run details link. Pass
the github.server_url context. | -| suite-result | string | false | | finish mode. Result of the suite
job. Pass the matching needs result. | -| summary | string | false | | finish mode. Markdown body for the
completed check-run. Defaults to the two
job results. | -| target-name | string | false | | start mode. Target represented by this
invocation. Setting it enables --target parsing;
a different explicit target returns target-not-selected
without opening a check. | +| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION | +|--------------------|--------|----------|------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| allow-forks | string | false | `"false"` | start mode. Allow fork pull requests
only when the commenter has write,
maintain, or admin repository permission. Disabled
by default. | +| allowed-targets | string | false | `"pro oss"` | start mode. Whitespace-separated target values accepted
when target-name is set. Each value
needs a matching action invocation. | +| app-client-id | string | false | | Optional client ID of a GitHub
App with checks: write. With app-private-key,
the check-run gets its own check
suite. Pass the same pair to
start and finish. Empty keeps github-token. | +| app-private-key | string | false | | Optional private key for app-client-id. | +| author-association | string | false | | start mode. How the commenter relates
to the repository. Pass the github.event.comment.author_association
context. OWNER, MEMBER and COLLABORATOR may
run the command; anything else, including
empty, may not. | +| build-result | string | false | | finish mode. Result of the build
job. Pass the matching needs result. | +| check-name-prefix | string | false | `"e2e"` | start mode. Prefix for the check-run
name; the filter and optional target
and focus are appended. | +| check-run-id | string | false | | finish and link modes. The id
returned by start mode. Empty is
not an error; it means no
check was opened. | +| command | string | false | `"/test-e2e"` | Command word that must open the
comment, on its own first line. | +| comment-author | string | false | | start mode. Login of the commenter.
Pass the github.event.comment.user.login context. | +| comment-body | string | false | | start mode. The comment text. Pass
the github.event.comment.body context. | +| comment-id | string | false | | start mode. Id of the command
comment, which the reply answers. Pass
the github.event.comment.id context. | +| details-url | string | false | | finish mode. Link target for the
completed check-run. | +| finish-workflow | string | false | `"e2e-ginkgo.yaml"` | start mode. Workflow file that runs
finish. The app opens the check
only if that file passes both
app inputs to finish. | +| github-token | string | false | `"${{ github.token }}"` | Token for gh. The calling job
must grant checks: write and pull-requests:
read. | +| mode | string | true | | One of "start", "finish" or "link". | +| parse-focus | string | false | `"false"` | start mode. Set to true to
split an optional trailing --focus expression
from the filter. Disabled by default
for existing consumers. | +| parse-repeat | string | false | `"false"` | start mode. Set to true to
accept `--repeat N` (1 to 10) before --focus: one
run whose suite repeats N extra
times. Disabled by default for existing
consumers. | +| parse-runs | string | false | `"false"` | start mode. Set to true to
accept `--runs N` (1 to 5) before --focus: N
independent runs, each with its own
check-run and concurrency key. Cannot be
combined with --repeat. Disabled by default
for existing consumers. | +| pr-number | string | false | | start mode. Pull request number. Pass
the github.event.issue.number context. | +| reply | string | false | `"false"` | 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. | +| repo | string | false | `"${{ github.repository }}"` | Repository in owner/name form. | +| report-conclusion | string | false | | finish mode. What the test run
declared about itself, parsed from its
report. The only input that can
produce a neutral conclusion. | +| run-id | string | false | | start mode. Used to build the
check-run details link. Pass the github.run_id
context. | +| run-label | string | false | | start mode. Short name for this
invocation's runs in the reply, such
as "Pro". Defaults to check-name-prefix. | +| run-url | string | false | | link mode. URL of the run
dispatched for check-run-id. The reply links
it. | +| server-url | string | false | `"https://github.com"` | Base URL for the check-run details
link and the reply's links. Pass
the github.server_url context. | +| suite-result | string | false | | finish mode. Result of the suite
job. Pass the matching needs result. | +| summary | string | false | | finish mode. Markdown body for the
completed check-run. Defaults to the two
job results. | +| target-name | string | false | | start mode. Target represented by this
invocation. Setting it enables --target parsing;
a different explicit target returns target-not-selected
without opening a check. | diff --git a/.github/actions/comment-triggered-check/action.yml b/.github/actions/comment-triggered-check/action.yml index c13af0a8..aa6f9bca 100644 --- a/.github/actions/comment-triggered-check/action.yml +++ b/.github/actions/comment-triggered-check/action.yml @@ -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.' @@ -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. The reply links 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: @@ -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 @@ -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 @@ -234,9 +257,22 @@ 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 }} + GH_TOKEN: ${{ inputs.github-token }} + run: ${{ github.action_path }}/src/link.sh branding: icon: 'play-circle' color: 'orange' diff --git a/.github/actions/comment-triggered-check/src/finish.sh b/.github/actions/comment-triggered-check/src/finish.sh index 81d9d08a..a8819ee7 100755 --- a/.github/actions/comment-triggered-check/src/finish.sh +++ b/.github/actions/comment-triggered-check/src/finish.sh @@ -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}" @@ -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. +reply_id="$(reply_id_of_check "$repo" "$check_run_id")" +if [[ -n "$reply_id" ]]; then + reply_refresh "$repo" "$reply_id" +fi + echo "::notice::published ${conclusion} to check-run ${check_run_id}" emit "conclusion" "$conclusion" diff --git a/.github/actions/comment-triggered-check/src/lib.sh b/.github/actions/comment-triggered-check/src/lib.sh index 79b84f3b..e1de65c9 100755 --- a/.github/actions/comment-triggered-check/src/lib.sh +++ b/.github/actions/comment-triggered-check/src/lib.sh @@ -531,6 +531,100 @@ refusal_details() { esac } +# --- Reply comment ------------------------------------------------------------ +# One reply per command comment, redrawn from the command's check-runs. The +# state block carries what the checks cannot: labels and run links. It is +# base64 so user text can never close the HTML comment around it. + +REPLY_MARKER_PREFIX="' "$REPLY_MARKER_PREFIX" "${1-}" +} + +# reply_state_decode — 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 (.runs | type) == "array"' <<< "$json" >/dev/null 2>&1 || return 1 + jq -c . <<< "$json" +} + +# run_label