diff --git a/actions/first-merged-pr-comment/action.yml b/actions/first-merged-pr-comment/action.yml new file mode 100644 index 0000000..d3efc83 --- /dev/null +++ b/actions/first-merged-pr-comment/action.yml @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: First merged PR comment +description: Comment on a contributor's first merged pull request using a strict message template. + +inputs: + github-token: + description: Token used to inspect merged pull requests and create the comment. + required: true + message-template: + description: Message template rendered with built-in placeholders. + required: true + pull-request-number: + description: Pull request number. Required for manual retries; otherwise read from the event. + required: false + default: '' + +outputs: + is-first-merged: + description: Whether the pull request is the contributor's first merged pull request. + value: ${{ steps.comment.outputs.is-first-merged }} + comment-created: + description: Whether this invocation created the comment. + value: ${{ steps.comment.outputs.comment-created }} + contributor-login: + description: Contributor login resolved from the pull request. + value: ${{ steps.comment.outputs.contributor-login }} + pull-request-number: + description: Pull request number that was processed. + value: ${{ steps.comment.outputs.pull-request-number }} + +runs: + using: composite + steps: + - id: comment + name: Comment on first merged pull request + shell: bash + env: + FIRST_MERGED_PR_GITHUB_TOKEN: ${{ inputs.github-token }} + FIRST_MERGED_PR_MESSAGE_TEMPLATE: ${{ inputs.message-template }} + FIRST_MERGED_PR_NUMBER: ${{ inputs.pull-request-number }} + run: | + set -euo pipefail + python3 "${GITHUB_ACTION_PATH}/first_merged_pr_comment.py" diff --git a/actions/first-merged-pr-comment/first_merged_pr_comment.py b/actions/first-merged-pr-comment/first_merged_pr_comment.py new file mode 100755 index 0000000..8d0a924 --- /dev/null +++ b/actions/first-merged-pr-comment/first_merged_pr_comment.py @@ -0,0 +1,295 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +from __future__ import annotations + +import json +import os +import re +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any, Callable + +MARKER = "" +PLACEHOLDER = re.compile(r"\{([a-z][a-z0-9_]*)(?:\|([a-z][a-z0-9_]*))?\}") +ALLOWED_FILTERS = {"urlencode"} +ApiRequest = Callable[[str, str, str, dict[str, Any] | None], Any] + + +class ActionError(RuntimeError): + pass + + +def render_template(template: str, context: dict[str, str]) -> str: + if not template.strip(): + raise ActionError("message template is empty") + + def replace(match: re.Match[str]) -> str: + name, filter_name = match.groups() + if name not in context: + raise ActionError(f"unknown placeholder: {name}") + value = context[name] + if filter_name is None: + return value + if filter_name not in ALLOWED_FILTERS: + raise ActionError(f"unknown placeholder filter: {filter_name}") + return urllib.parse.quote(value, safe="") + + return PLACEHOLDER.sub(replace, template) + + +def build_context( + *, + pr: dict[str, Any], + repository: str, + server_url: str, + api_url: str, +) -> dict[str, str]: + owner, repository_name = repository.split("/", 1) + login = str(pr["user"]["login"]) + number = str(pr["number"]) + clean_server_url = server_url.rstrip("/") + return { + "server_url": clean_server_url, + "api_url": api_url.rstrip("/"), + "repository": repository, + "repository_owner": owner, + "repository_name": repository_name, + "repository_url": f"{clean_server_url}/{repository}", + "pull_request_number": number, + "pull_request_url": str( + pr.get("html_url") + or f"{clean_server_url}/{repository}/pull/{number}" + ), + "contributor_login": login, + "contributor_mention": f"@{login}", + "contributor_url": f"{clean_server_url}/{login}", + "merge_commit_sha": str(pr.get("merge_commit_sha") or ""), + } + + +def build_api_request( + method: str, + url: str, + token: str, + payload: dict[str, Any] | None = None, +) -> urllib.request.Request: + data = None if payload is None else json.dumps(payload).encode("utf-8") + request = urllib.request.Request( + url, + data=data, + method=method, + headers={ + "Accept": "application/vnd.github+json", + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + # Keep credentials off redirected requests. urllib forwards normal headers + # across redirects, which could otherwise disclose the GitHub token if an + # API endpoint ever redirected to a different origin. + request.add_unredirected_header("Authorization", f"Bearer {token}") + return request + + +def api_request( + method: str, + url: str, + token: str, + payload: dict[str, Any] | None = None, +) -> Any: + request = build_api_request(method, url, token, payload) + try: + with urllib.request.urlopen(request, timeout=30) as response: + body = response.read().decode("utf-8") + except urllib.error.HTTPError as error: + body = error.read().decode("utf-8", errors="replace") + raise ActionError(f"GitHub API request failed ({error.code}): {body}") from error + return json.loads(body) if body else None + + +def pull_request_from_event(event_path: str) -> dict[str, Any] | None: + if not event_path: + return None + payload = json.loads(Path(event_path).read_text(encoding="utf-8")) + pr = payload.get("pull_request") + return pr if isinstance(pr, dict) else None + + +def write_output(name: str, value: str) -> None: + path = os.environ.get("GITHUB_OUTPUT") + if not path: + return + with Path(path).open("a", encoding="utf-8") as handle: + handle.write(f"{name}={value}\n") + + +def previous_merged_query(repository: str, login: str, closed_at: str) -> str: + return " ".join( + ( + f"repo:{repository}", + "is:pr", + "is:merged", + f"author:{login}", + f"closed:<{closed_at}", + ) + ) + + +def has_action_marker_comment( + *, + api_url: str, + repository: str, + pull_request_number: int, + token: str, + request: ApiRequest = api_request, +) -> bool: + owner, repo = repository.split("/", 1) + page = 1 + while True: + batch = request( + "GET", + f"{api_url}/repos/{owner}/{repo}/issues/{pull_request_number}/comments" + f"?per_page=100&page={page}", + token, + None, + ) + for comment in batch: + author = comment.get("user") or {} + if ( + author.get("type") == "Bot" + and MARKER in str(comment.get("body") or "") + ): + return True + if len(batch) < 100: + return False + page += 1 + + +def process_pull_request( + *, + pr: dict[str, Any], + repository: str, + token: str, + api_url: str, + server_url: str, + template: str, + request: ApiRequest = api_request, +) -> dict[str, str]: + result = { + "is-first-merged": "false", + "comment-created": "false", + "contributor-login": str(pr["user"]["login"]), + "pull-request-number": str(pr["number"]), + } + + if not pr.get("merged") or pr.get("user", {}).get("type") == "Bot": + return result + + login = str(pr["user"]["login"]) + query = previous_merged_query(repository, login, str(pr["closed_at"])) + encoded_query = urllib.parse.urlencode({"q": query, "per_page": 1}) + search = request( + "GET", + f"{api_url}/search/issues?{encoded_query}", + token, + None, + ) + # Search only for earlier merged PRs. Do not require the current PR to + # have reached the search index yet; the closed event can arrive before + # search indexing catches up. + if int(search["total_count"]) != 0: + return result + + result["is-first-merged"] = "true" + + if has_action_marker_comment( + api_url=api_url, + repository=repository, + pull_request_number=int(pr["number"]), + token=token, + request=request, + ): + return result + + context = build_context( + pr=pr, + repository=repository, + server_url=server_url, + api_url=api_url, + ) + message = render_template(template, context).strip() + + owner, repo = repository.split("/", 1) + request( + "POST", + f"{api_url}/repos/{owner}/{repo}/issues/{pr['number']}/comments", + token, + {"body": f"{MARKER}\n{message}"}, + ) + result["comment-created"] = "true" + return result + + +def main() -> int: + token = os.environ.get("FIRST_MERGED_PR_GITHUB_TOKEN", "") + template = os.environ.get("FIRST_MERGED_PR_MESSAGE_TEMPLATE", "") + manual_number = os.environ.get("FIRST_MERGED_PR_NUMBER", "").strip() + repository = os.environ.get("GITHUB_REPOSITORY", "") + api_url = os.environ.get("GITHUB_API_URL", "https://api.github.com").rstrip("/") + server_url = os.environ.get("GITHUB_SERVER_URL", "https://github.com").rstrip("/") + + if not token: + raise ActionError("github token is required") + if "/" not in repository: + raise ActionError("GITHUB_REPOSITORY must be in owner/name form") + + pr = pull_request_from_event(os.environ.get("GITHUB_EVENT_PATH", "")) + if pr is None: + if not manual_number.isdigit() or int(manual_number) <= 0: + raise ActionError("a valid pull-request-number is required for a manual run") + owner, repo = repository.split("/", 1) + pr = api_request( + "GET", + f"{api_url}/repos/{owner}/{repo}/pulls/{int(manual_number)}", + token, + ) + + result = process_pull_request( + pr=pr, + repository=repository, + token=token, + api_url=api_url, + server_url=server_url, + template=template, + ) + for name, value in result.items(): + write_output(name, value) + + if result["comment-created"] == "true": + print( + f"Created first-merged contribution comment on " + f"PR #{result['pull-request-number']}." + ) + elif result["is-first-merged"] == "true": + print( + f"PR #{result['pull-request-number']} already has a " + "first-merged contribution comment; skipping." + ) + else: + print( + f"PR #{result['pull-request-number']} is not the contributor's " + "first merged pull request; skipping." + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (ActionError, KeyError, OSError, ValueError, json.JSONDecodeError) as error: + print(f"::error::{error}") + raise SystemExit(1) from error diff --git a/docs/first-merged-pr-comment.md b/docs/first-merged-pr-comment.md new file mode 100644 index 0000000..706882f --- /dev/null +++ b/docs/first-merged-pr-comment.md @@ -0,0 +1,123 @@ + + +# First merged PR comment + +The `first-merged-pr-comment` action detects a contributor's first merged pull +request and creates one idempotent comment from a repository-defined template. + +The action is intentionally generic. It has no concept of surveys, community +links, documentation URLs, labels, or any other product-specific destination. +The consumer composes the complete message, including arbitrary URLs and query +strings, from built-in placeholders. + +## Action contract + +Required inputs: + +- `github-token`: token used for the GitHub API; +- `message-template`: complete Markdown message template. + +Optional input: + +- `pull-request-number`: used by manual retries. Event-driven executions read + the pull request from the event payload. + +Outputs: + +- `is-first-merged`; +- `comment-created`; +- `contributor-login`; +- `pull-request-number`. + +## Built-in placeholders + +The message renderer exposes only GitHub-derived values: + +- `{server_url}` +- `{api_url}` +- `{repository}` +- `{repository_owner}` +- `{repository_name}` +- `{repository_url}` +- `{pull_request_number}` +- `{pull_request_url}` +- `{contributor_login}` +- `{contributor_mention}` +- `{contributor_url}` +- `{merge_commit_sha}` + +Any placeholder may use the `urlencode` filter when it must be embedded in a +URL component: + +```text +https://example.org/form?repo={repository|urlencode}&pr={pull_request_number|urlencode} +``` + +The renderer performs textual substitution only. It does not evaluate shell, +Python, JavaScript, GitHub expressions, Jinja, Handlebars, or template +functions. Unknown placeholders and unknown filters fail the action instead of +publishing a partially rendered message. + +## Repository configuration + +The organization workflow template reads the optional repository variable +`FIRST_MERGED_PR_MESSAGE`. If it is absent, the installed workflow uses a +minimal generic message. + +A consumer can put its entire Markdown message in that single variable. For +example: + +```text +Thanks {contributor_mention}! Your first pull request to {repository_name} has been merged. + +Repository: {repository_url} + +Feedback: https://feedback.example/respond?repository={repository|urlencode}&contributor={contributor_login|urlencode}&pr={pull_request_number|urlencode} +``` + +The URLs above are examples only. The action does not know or assign meaning to +them. + +## Retry and idempotency + +The workflow template supports `workflow_dispatch` with a pull request number. +The action evaluates the contribution at the historical close time of that pull +request, so a retry remains valid even after the contributor has additional +merged pull requests. + +Successful comments include an internal HTML marker. A retry against a pull +request that already received the message exits successfully without creating a +duplicate. + +## Security model + +The workflow uses `pull_request_target` because the comment requires write +permission after a pull request from a fork is merged. The privileged workflow +must therefore never execute untrusted pull-request content. + +The provided template: + +- starts from `permissions: {}`; +- grants only `pull-requests: write` to the job; +- does not check out repository or pull-request content; +- does not download or execute pull-request artifacts; +- calls the LibreCode composite action at an immutable commit; +- uses Python's standard library only; +- keeps the GitHub authorization header off redirected requests; +- only trusts the idempotency marker when it appears in a bot-authored comment; +- does not build or run a Docker image; +- does not install runtime dependencies. + +Do not add checkout or execution of pull-request-head content to this workflow. + + +### pull_request_target policy + +GitHub treats `pull_request_target` as a privileged event. Repositories or +organizations that restrict this event must explicitly allow this workflow. +The workflow is designed for that privileged model: it never checks out or +executes pull-request-head content and requests only the permission needed to +create the pull-request comment. diff --git a/tests/test_first_merged_pr_comment_action.py b/tests/test_first_merged_pr_comment_action.py new file mode 100644 index 0000000..afa40a1 --- /dev/null +++ b/tests/test_first_merged_pr_comment_action.py @@ -0,0 +1,242 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +from __future__ import annotations + +import importlib.util +import unittest +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "actions" / "first-merged-pr-comment" / "first_merged_pr_comment.py" + +spec = importlib.util.spec_from_file_location("first_merged_pr_comment", SCRIPT) +assert spec is not None and spec.loader is not None +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +class FakeApi: + def __init__(self, *, total_count: int = 0, comments: list[dict[str, Any]] | None = None) -> None: + self.total_count = total_count + self.comments = comments or [] + self.calls: list[tuple[str, str, dict[str, Any] | None]] = [] + + def __call__( + self, + method: str, + url: str, + token: str, + payload: dict[str, Any] | None = None, + ) -> Any: + self.calls.append((method, url, payload)) + if "/search/issues?" in url: + return {"total_count": self.total_count} + if "/comments?" in url: + return self.comments + if method == "POST" and url.endswith("/comments"): + return {"id": 123} + raise AssertionError(f"unexpected API request: {method} {url}") + + +class FirstMergedPrCommentTest(unittest.TestCase): + def setUp(self) -> None: + self.pr = { + "number": 42, + "html_url": "https://git.example/acme/project/pull/42", + "merged": True, + "closed_at": "2026-09-23T12:00:00Z", + "merge_commit_sha": "abc123", + "user": {"login": "alice", "type": "User"}, + } + + def process(self, api: FakeApi, *, pr: dict[str, Any] | None = None, template: str = "Thanks {contributor_mention}") -> dict[str, str]: + return module.process_pull_request( + pr=pr or self.pr, + repository="acme/project", + token="token", + api_url="https://git.example/api/v3", + server_url="https://git.example", + template=template, + request=api, + ) + + def test_build_context_is_generic(self) -> None: + context = module.build_context( + pr=self.pr, + repository="acme/project", + server_url="https://git.example", + api_url="https://git.example/api/v3", + ) + self.assertEqual(context["server_url"], "https://git.example") + self.assertEqual(context["api_url"], "https://git.example/api/v3") + self.assertEqual(context["repository"], "acme/project") + self.assertEqual(context["repository_owner"], "acme") + self.assertEqual(context["repository_name"], "project") + self.assertEqual(context["repository_url"], "https://git.example/acme/project") + self.assertEqual(context["pull_request_number"], "42") + self.assertEqual(context["pull_request_url"], "https://git.example/acme/project/pull/42") + self.assertEqual(context["contributor_login"], "alice") + self.assertEqual(context["contributor_mention"], "@alice") + self.assertEqual(context["contributor_url"], "https://git.example/alice") + self.assertEqual(context["merge_commit_sha"], "abc123") + + def test_render_template_composes_arbitrary_urls(self) -> None: + context = module.build_context( + pr=self.pr, + repository="acme/project", + server_url="https://git.example", + api_url="https://git.example/api/v3", + ) + rendered = module.render_template( + "Hello {contributor_mention}. " + "Docs: {repository_url}/docs. " + "Feedback: https://forms.example/respond?repo={repository|urlencode}" + "&user={contributor_login|urlencode}&pr={pull_request_number}.", + context, + ) + self.assertEqual( + rendered, + "Hello @alice. Docs: https://git.example/acme/project/docs. " + "Feedback: https://forms.example/respond?repo=acme%2Fproject" + "&user=alice&pr=42.", + ) + + def test_render_template_rejects_unknown_placeholder(self) -> None: + with self.assertRaisesRegex(module.ActionError, "unknown placeholder: custom_url"): + module.render_template("{custom_url}", {"repository": "acme/project"}) + + def test_render_template_rejects_unknown_filter(self) -> None: + with self.assertRaisesRegex(module.ActionError, "unknown placeholder filter: shell"): + module.render_template("{repository|shell}", {"repository": "acme/project"}) + + def test_render_template_rejects_empty_message(self) -> None: + with self.assertRaisesRegex(module.ActionError, "message template is empty"): + module.render_template(" ", {}) + + def test_first_merged_pr_creates_comment(self) -> None: + api = FakeApi() + result = self.process( + api, + template=( + "Thanks {contributor_mention}. " + "{server_url}/{repository}/issues?author={contributor_login|urlencode}" + ), + ) + self.assertEqual(result["is-first-merged"], "true") + self.assertEqual(result["comment-created"], "true") + post = [call for call in api.calls if call[0] == "POST"] + self.assertEqual(len(post), 1) + self.assertIn(module.MARKER, post[0][2]["body"]) + self.assertIn("Thanks @alice.", post[0][2]["body"]) + + def test_second_merged_pr_does_not_create_comment(self) -> None: + api = FakeApi(total_count=1) + result = self.process(api) + self.assertEqual(result["is-first-merged"], "false") + self.assertEqual(result["comment-created"], "false") + self.assertFalse(any(call[0] == "POST" for call in api.calls)) + + def test_closed_unmerged_pr_does_not_call_api(self) -> None: + api = FakeApi() + pr = dict(self.pr, merged=False) + result = self.process(api, pr=pr) + self.assertEqual(result["is-first-merged"], "false") + self.assertEqual(result["comment-created"], "false") + self.assertEqual(api.calls, []) + + def test_bot_pr_does_not_call_api(self) -> None: + api = FakeApi() + pr = dict(self.pr, user={"login": "renovate[bot]", "type": "Bot"}) + result = self.process(api, pr=pr) + self.assertEqual(result["comment-created"], "false") + self.assertEqual(api.calls, []) + + def test_existing_bot_marker_makes_retry_idempotent(self) -> None: + api = FakeApi( + comments=[ + { + "body": f"{module.MARKER}\nAlready sent", + "user": {"login": "github-actions[bot]", "type": "Bot"}, + } + ] + ) + result = self.process(api) + self.assertEqual(result["is-first-merged"], "true") + self.assertEqual(result["comment-created"], "false") + self.assertFalse(any(call[0] == "POST" for call in api.calls)) + + def test_user_cannot_suppress_comment_by_copying_marker(self) -> None: + api = FakeApi( + comments=[ + { + "body": f"{module.MARKER}\nSpoofed", + "user": {"login": "alice", "type": "User"}, + } + ] + ) + result = self.process(api) + self.assertEqual(result["is-first-merged"], "true") + self.assertEqual(result["comment-created"], "true") + + def test_previous_merged_query_excludes_current_pr(self) -> None: + query = module.previous_merged_query( + "acme/project", + "alice", + "2026-09-23T12:00:00Z", + ) + self.assertEqual( + query, + "repo:acme/project is:pr is:merged author:alice " + "closed:<2026-09-23T12:00:00Z", + ) + + def test_first_merge_does_not_depend_on_current_pr_search_indexing(self) -> None: + api = FakeApi(total_count=0) + result = self.process(api) + self.assertEqual(result["is-first-merged"], "true") + self.assertEqual(result["comment-created"], "true") + + def test_authorization_header_is_not_forwarded_on_redirects(self) -> None: + request = module.build_api_request( + "GET", + "https://api.github.com/repos/acme/project", + "secret-token", + ) + self.assertNotIn("Authorization", request.headers) + self.assertEqual( + request.unredirected_hdrs["Authorization"], + "Bearer secret-token", + ) + + def test_marker_is_stable_for_idempotency(self) -> None: + self.assertEqual( + module.MARKER, + "", + ) + + def test_action_contract_has_no_product_specific_inputs(self) -> None: + action = ( + ROOT / "actions" / "first-merged-pr-comment" / "action.yml" + ).read_text(encoding="utf-8") + self.assertIn("message-template:", action) + self.assertIn("pull-request-number:", action) + self.assertNotIn("survey", action.lower()) + self.assertNotIn("community", action.lower()) + self.assertNotIn("good first issue", action.lower()) + + def test_workflow_template_only_exposes_message_configuration(self) -> None: + workflow = ( + ROOT / "workflow-templates" / "first-merged-pr-comment.yml" + ).read_text(encoding="utf-8") + self.assertIn("vars.FIRST_MERGED_PR_MESSAGE", workflow) + self.assertIn("message-template:", workflow) + self.assertNotIn("survey", workflow.lower()) + self.assertNotIn("community", workflow.lower()) + self.assertNotIn("good first issue", workflow.lower()) + self.assertNotIn("uses: actions/checkout@", workflow) + + +if __name__ == "__main__": + unittest.main() diff --git a/workflow-catalog.json b/workflow-catalog.json index 24ff745..0b3d8e0 100644 --- a/workflow-catalog.json +++ b/workflow-catalog.json @@ -2,6 +2,7 @@ "templates": [ "appstore-build-publish", "block-unconventional-commits", + "first-merged-pr-comment", "lint-eslint", "lint-info-xml", "lint-php", diff --git a/workflow-templates/first-merged-pr-comment.properties.json b/workflow-templates/first-merged-pr-comment.properties.json new file mode 100644 index 0000000..11da31a --- /dev/null +++ b/workflow-templates/first-merged-pr-comment.properties.json @@ -0,0 +1,8 @@ +{ + "name": "First merged PR comment", + "description": "Comment on a contributor's first merged pull request with a repository-defined message template.", + "iconName": "octicon comment", + "categories": [ + "Code Review" + ] +} diff --git a/workflow-templates/first-merged-pr-comment.properties.json.license b/workflow-templates/first-merged-pr-comment.properties.json.license new file mode 100644 index 0000000..1ce4e0c --- /dev/null +++ b/workflow-templates/first-merged-pr-comment.properties.json.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/workflow-templates/first-merged-pr-comment.yml b/workflow-templates/first-merged-pr-comment.yml new file mode 100644 index 0000000..7e6a3dc --- /dev/null +++ b/workflow-templates/first-merged-pr-comment.yml @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: First merged PR comment + +on: + pull_request_target: + types: [closed] + workflow_dispatch: + inputs: + pull_request_number: + description: Pull request number to process or retry + required: true + type: string + +permissions: {} + +concurrency: + group: >- + first-merged-pr-comment-${{ + github.event.pull_request.number || + inputs.pull_request_number || + github.run_id + }} + cancel-in-progress: false + +jobs: + first-merged-pr-comment: + if: >- + github.event_name == 'workflow_dispatch' || + ( + github.event.pull_request.merged == true && + github.event.pull_request.user.type != 'Bot' + ) + runs-on: ubuntu-latest + timeout-minutes: 5 + + permissions: + pull-requests: write + + env: + FIRST_MERGED_PR_MESSAGE: >- + ${{ vars.FIRST_MERGED_PR_MESSAGE || + 'Thanks {contributor_mention}! Your first pull request to {repository_name} has been merged.' }} + + steps: + # pull_request_target is intentionally used without checkout. Nothing + # from the pull request head is downloaded or executed. + - name: Comment on first merged pull request + uses: LibreCodeCoop/github-workflows/actions/first-merged-pr-comment@5c26716fb3e541ff7a501a391aecec5ff8ec7b92 + with: + github-token: ${{ github.token }} + pull-request-number: >- + ${{ github.event.pull_request.number || inputs.pull_request_number }} + message-template: ${{ env.FIRST_MERGED_PR_MESSAGE }}