diff --git a/.github/actions/comment-triggered-check/README.md b/.github/actions/comment-triggered-check/README.md
index 7ee3d14a..06a13818 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,49 @@ 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
+stamps each check-run it opens with a small JSON `external_id`: the reply id,
+the workflow attempt, the row label and, once linked, the run URL. 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. `link` adds the URL to the check's `external_id`, so it
+edits the check: pass it the same app inputs as `start` when the app opened the
+check. `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 its checks, stamped with the new attempt, replace the rows. The reply
+is posted saying the command is starting, never that it failed, because its
+first version is the one notification emails carry.
+
+The reply is a view of the check-runs. Its hidden state holds only the command
+and the commit; which rows exist, their labels, status and run links all come
+from the checks on the commit stamped with this reply, newest attempt only.
+Every writer lists those checks, redraws the whole reply, reads it back, and
+writes again if it differs. A job that overwrites the reply with an older view,
+such as a finish from a cancelled attempt, is corrected by the next redraw of
+any job, because nothing it wrote back is a source of truth. GitHub has no
+conditional comment edit, so this is what keeps parallel jobs from losing each
+other's rows or links. A reply that cannot be written only warns; it never
+fails `start`, `link` or `finish`. Only a bot's comment is taken as an existing
+reply, so a person cannot plant the marker, and a run link is drawn only when it
+is a plain https URL.
+
+Run the `start` calls for one command one after another, as separate steps of
+one job. Two `start` calls running in parallel could both find no reply and
+post two.
+
## Permissions
The calling job needs `checks: write` to create and complete the check-run, and
@@ -257,38 +303,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. Stored on the check, so link edits the check: pass the same app inputs as start when the app opened 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. |
@@ -426,7 +480,7 @@ second check-run for the same filter. The error message names the distinction.
## Reporting failures to the author
-The action deliberately posts no comments. It emits the machine-readable
+Apart from the opt-in `reply`, the action posts no comments. It emits the machine-readable
`reason` plus presentation-ready `reason-title` and `reason-guidance`, which
keeps it usable by repositories that surface results differently. Pair it with
`sticky-pr-comment` when the caller wants a comment.
diff --git a/.github/actions/comment-triggered-check/action.yml b/.github/actions/comment-triggered-check/action.yml
index c13af0a8..abd300bb 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. 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:
@@ -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,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'
diff --git a/.github/actions/comment-triggered-check/src/finish.sh b/.github/actions/comment-triggered-check/src/finish.sh
index 81d9d08a..d0e2a421 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.
+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"
diff --git a/.github/actions/comment-triggered-check/src/lib.sh b/.github/actions/comment-triggered-check/src/lib.sh
index 79b84f3b..01141a9b 100755
--- a/.github/actions/comment-triggered-check/src/lib.sh
+++ b/.github/actions/comment-triggered-check/src/lib.sh
@@ -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="' "$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 (.command | type) == "string"' <<< "$json" >/dev/null 2>&1 || return 1
+ jq -c . <<< "$json"
+}
+
+# is_run_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