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