From 37a264b1b2d8ffb47966b14ea4dec3a47457e09c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franc=CC=A7ois=20Benaiteau?= Date: Tue, 29 Sep 2026 11:01:29 +0200 Subject: [PATCH 01/12] feat: add custom GitHub Actions notifications --- .github/actions/notify-wire/action.yml | 37 +++++++++++++++ README.md | 21 +++++++++ src/main/kotlin/com/wire/github/Routing.kt | 45 +++++++++++++++++++ .../wire/github/util/SignatureValidator.kt | 11 +++++ .../kotlin/com/wire/github/ApplicationTest.kt | 29 ++++++++++++ 5 files changed, 143 insertions(+) create mode 100644 .github/actions/notify-wire/action.yml diff --git a/.github/actions/notify-wire/action.yml b/.github/actions/notify-wire/action.yml new file mode 100644 index 0000000..0e4f8da --- /dev/null +++ b/.github/actions/notify-wire/action.yml @@ -0,0 +1,37 @@ +name: Notify Wire +description: Send a custom message to a Wire conversation through github-app +inputs: + endpoint: + description: Base URL of github-app (for example https://github-app.example.com) + required: true + conversation-id: + description: Wire conversation UUID + required: true + conversation-domain: + description: Wire conversation domain + required: true + secret: + description: Conversation secret configured for github-app + required: true + text: + description: Message text to send + required: true +runs: + using: composite + steps: + - name: Send message to Wire + shell: bash + env: + GHAPP_ENDPOINT: ${{ inputs.endpoint }} + CONVERSATION_ID: ${{ inputs.conversation-id }} + CONVERSATION_DOMAIN: ${{ inputs.conversation-domain }} + CONVERSATION_SECRET: ${{ inputs.secret }} + MESSAGE_TEXT: ${{ inputs.text }} + run: | + set -euo pipefail + python3 -c 'import json, os; print(json.dumps({"text": os.environ["MESSAGE_TEXT"]}))' > "$RUNNER_TEMP/wire-message.json" + curl --fail-with-body --silent --show-error \ + -X POST "${GHAPP_ENDPOINT%/}/actions/${CONVERSATION_ID}/${CONVERSATION_DOMAIN}" \ + -H "Authorization: Bearer ${CONVERSATION_SECRET}" \ + -H "Content-Type: application/json" \ + --data-binary "@$RUNNER_TEMP/wire-message.json" diff --git a/README.md b/README.md index 5953749..ad2e2a9 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,27 @@ Here's a list of features included in this project: |------------------------------------------|--------------------------------------------| | /health | Healthcheck endpoint returning HTTP OK 200. | | /{conversation_id}/{conversation_domain} | Webhook endpoint. | +| /actions/{conversation_id}/{conversation_domain} | Send a custom GitHub Actions message. | + +### GitHub Actions notifications + +Use the reusable composite action to send workflow-composed text to a Wire conversation. The endpoint authenticates with the conversation secret already configured for GitHub webhooks. Store it as a GitHub Actions secret and do not print it in workflow logs. Empty or whitespace-only messages are rejected with `400`; a missing/invalid secret is rejected with `403`. + +```yaml +- name: Notify Wire on failure + if: failure() || cancelled() + uses: wireapp/github-app/.github/actions/notify-wire@v1 + with: + endpoint: https://github-app.example.com + conversation-id: ${{ vars.WIRE_CONVERSATION_ID }} + conversation-domain: ${{ vars.WIRE_CONVERSATION_DOMAIN }} + secret: ${{ secrets.WIRE_CONVERSATION_SECRET }} + text: | + CI failed for ${{ github.repository }} on `${{ github.ref_name }}`. + Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} +``` + +The action fails the workflow if the notification request fails; callers can opt into `continue-on-error: true` when notification delivery should not affect the job result. Pin the action to a released tag or commit SHA. ## Building & Running diff --git a/src/main/kotlin/com/wire/github/Routing.kt b/src/main/kotlin/com/wire/github/Routing.kt index 0f4075e..772fa6e 100644 --- a/src/main/kotlin/com/wire/github/Routing.kt +++ b/src/main/kotlin/com/wire/github/Routing.kt @@ -25,6 +25,7 @@ import java.io.IOException import java.util.UUID import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerializationException +import kotlinx.serialization.Serializable import org.koin.core.context.GlobalContext @Suppress("LongMethod") @@ -143,8 +144,52 @@ fun Application.configureRouting() { return@post call.response.status(HttpStatusCode.OK) } + + post("/actions/{$PARAM_CONVERSATION_ID}/{$PARAM_CONVERSATION_DOMAIN}") { + val conversationId = call.parameters[PARAM_CONVERSATION_ID] + ?: return@post call.respond(HttpStatusCode.BadRequest) + val conversationDomain = call.parameters[PARAM_CONVERSATION_DOMAIN] + ?: return@post call.respond(HttpStatusCode.BadRequest) + val bearerToken = call.request.headers["Authorization"] + ?.takeIf { it.startsWith("Bearer ") } + ?.removePrefix("Bearer ") + ?.takeIf { it.isNotBlank() } + ?: return@post call.respond(HttpStatusCode.Forbidden) + + val isAuthorized = try { + signatureValidator.isBearerTokenValid(conversationId, conversationDomain, bearerToken) + } catch (exception: IOException) { + application.log.warn("No secret stored for conversation $conversationId@$conversationDomain") + false + } + if (!isAuthorized) return@post call.respond(HttpStatusCode.Forbidden) + + val request = try { + KtxSerializer.json.decodeFromString(call.receiveText()) + } catch (exception: SerializationException) { + return@post call.respond(HttpStatusCode.BadRequest) + } + if (request.text.isBlank()) return@post call.respond(HttpStatusCode.BadRequest) + + val conversationUuid = try { + UUID.fromString(conversationId) + } catch (exception: IllegalArgumentException) { + return@post call.respond(HttpStatusCode.BadRequest) + } + + wireAppSdk.getApplicationManager().sendMessage( + message = WireMessage.Text.create( + conversationId = QualifiedId(id = conversationUuid, domain = conversationDomain), + text = request.text + ) + ) + call.respond(HttpStatusCode.OK) + } } } private const val PARAM_CONVERSATION_ID = "conversationId" private const val PARAM_CONVERSATION_DOMAIN = "conversationDomain" + +@Serializable +private data class ActionsMessageRequest(val text: String) diff --git a/src/main/kotlin/com/wire/github/util/SignatureValidator.kt b/src/main/kotlin/com/wire/github/util/SignatureValidator.kt index 792b73f..7cd2f5b 100644 --- a/src/main/kotlin/com/wire/github/util/SignatureValidator.kt +++ b/src/main/kotlin/com/wire/github/util/SignatureValidator.kt @@ -3,6 +3,7 @@ package com.wire.github.util import io.lettuce.core.api.StatefulRedisConnection import java.io.IOException import java.util.Locale +import java.security.MessageDigest import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec import org.koin.core.context.GlobalContext @@ -11,6 +12,16 @@ class SignatureValidator { private val redisConnection = GlobalContext.get().get>() private val storage = redisConnection.sync() + fun isBearerTokenValid( + conversationId: String, + conversationDomain: String, + token: String + ): Boolean { + val secret = storage.get(conversationId.toStorageKey(domain = conversationDomain)) + ?: throw IOException("Missing secret for Conversation: $conversationId") + return MessageDigest.isEqual(secret.toByteArray(), token.toByteArray()) + } + @Throws(Exception::class) fun isValid( conversationId: String, diff --git a/src/test/kotlin/com/wire/github/ApplicationTest.kt b/src/test/kotlin/com/wire/github/ApplicationTest.kt index 9e7d198..055568f 100644 --- a/src/test/kotlin/com/wire/github/ApplicationTest.kt +++ b/src/test/kotlin/com/wire/github/ApplicationTest.kt @@ -290,6 +290,35 @@ class ApplicationTest { } } + @Test + fun `given valid bearer secret, when Actions message is posted, then message is sent`() { + val signatureValidator = mockk() + every { + signatureValidator.isBearerTokenValid( + CONVERSATION_ID.id.toString(), CONVERSATION_ID.domain, "conversation-secret" + ) + } returns true + val wireAppSdk = mockk(relaxed = true) + loadKoinModules( + module { + single { signatureValidator } + single { wireAppSdk } + } + ) + + testApplication { + application { configureRouting() } + val response = client.post("/actions/${CONVERSATION_ID.id}/${CONVERSATION_ID.domain}") { + contentType(ContentType.Application.Json) + header("Authorization", "Bearer conversation-secret") + setBody("""{"text":"CI failed"}""") + } + + assertEquals(HttpStatusCode.OK, response.status) + verify(exactly = 1) { wireAppSdk.getApplicationManager().sendMessage(message = any()) } + } + } + private companion object { val CONVERSATION_ID = QualifiedId( id = UUID.randomUUID(), From 5458fe490ea364ceaf07868c44fbb0004fddcd8b Mon Sep 17 00:00:00 2001 From: alexandreferris Date: Wed, 30 Sep 2026 10:10:35 +0200 Subject: [PATCH 02/12] feat: Add Github Custom Actions #WPB-28781 * Adjust Actions endpoint validation and authentication * Simplify `notify-wire` to URL, secret, and text inputs * Add Wire setup commands * Expand tests and documentation --- .github/actions/notify-wire/action.yml | 26 +-- README.md | 86 +++++-- .../kotlin/com/wire/github/EventsHandler.kt | 90 +++----- .../com/wire/github/GitHubCommandHandler.kt | 78 +++++++ src/main/kotlin/com/wire/github/Routing.kt | 122 ++++++---- .../kotlin/com/wire/github/config/Modules.kt | 2 + .../model/ActionsNotificationRequest.kt | 8 + .../wire/github/util/ActionsTokenValidator.kt | 24 ++ .../wire/github/util/SignatureValidator.kt | 11 - .../kotlin/com/wire/github/ApplicationTest.kt | 210 +++++++++++++++++- .../wire/github/GitHubCommandHandlerTest.kt | 90 ++++++++ .../github/util/ActionsTokenValidatorTest.kt | 47 ++++ 12 files changed, 629 insertions(+), 165 deletions(-) create mode 100644 src/main/kotlin/com/wire/github/GitHubCommandHandler.kt create mode 100644 src/main/kotlin/com/wire/github/request/model/ActionsNotificationRequest.kt create mode 100644 src/main/kotlin/com/wire/github/util/ActionsTokenValidator.kt create mode 100644 src/test/kotlin/com/wire/github/GitHubCommandHandlerTest.kt create mode 100644 src/test/kotlin/com/wire/github/util/ActionsTokenValidatorTest.kt diff --git a/.github/actions/notify-wire/action.yml b/.github/actions/notify-wire/action.yml index 0e4f8da..9ad020f 100644 --- a/.github/actions/notify-wire/action.yml +++ b/.github/actions/notify-wire/action.yml @@ -1,14 +1,8 @@ name: Notify Wire description: Send a custom message to a Wire conversation through github-app inputs: - endpoint: - description: Base URL of github-app (for example https://github-app.example.com) - required: true - conversation-id: - description: Wire conversation UUID - required: true - conversation-domain: - description: Wire conversation domain + webhook-url: + description: Full github-app Actions endpoint URL for the Wire conversation required: true secret: description: Conversation secret configured for github-app @@ -22,16 +16,14 @@ runs: - name: Send message to Wire shell: bash env: - GHAPP_ENDPOINT: ${{ inputs.endpoint }} - CONVERSATION_ID: ${{ inputs.conversation-id }} - CONVERSATION_DOMAIN: ${{ inputs.conversation-domain }} + WIRE_WEBHOOK_URL: ${{ inputs.webhook-url }} CONVERSATION_SECRET: ${{ inputs.secret }} MESSAGE_TEXT: ${{ inputs.text }} run: | set -euo pipefail - python3 -c 'import json, os; print(json.dumps({"text": os.environ["MESSAGE_TEXT"]}))' > "$RUNNER_TEMP/wire-message.json" - curl --fail-with-body --silent --show-error \ - -X POST "${GHAPP_ENDPOINT%/}/actions/${CONVERSATION_ID}/${CONVERSATION_DOMAIN}" \ - -H "Authorization: Bearer ${CONVERSATION_SECRET}" \ - -H "Content-Type: application/json" \ - --data-binary "@$RUNNER_TEMP/wire-message.json" + python3 -c 'import json, os; print(json.dumps({"text": os.environ["MESSAGE_TEXT"]}))' \ + | curl --fail-with-body --silent --show-error \ + -X POST "$WIRE_WEBHOOK_URL" \ + -H "Authorization: Bearer ${CONVERSATION_SECRET}" \ + -H "Content-Type: application/json" \ + --data-binary @- diff --git a/README.md b/README.md index 1ea1e90..692d8e2 100644 --- a/README.md +++ b/README.md @@ -23,25 +23,50 @@ No license is granted to the Wire trademark and its associated logos, all of whi Here's a list of features included in this project: -| Name | Description | -|------------------------------------------|--------------------------------------------| -| /health | Healthcheck endpoint returning HTTP OK 200. | -| /{conversation_id}/{conversation_domain} | Webhook endpoint. | -| /actions/{conversation_id}/{conversation_domain} | Send a custom GitHub Actions message. | +| Name | Description | +|--------------------------------------------------------|--------------------------------------------------| +| `/health` | Healthcheck endpoint returning HTTP OK 200. | +| `/{conversation_id}/{conversation_domain}` | Receive signed GitHub repository webhooks. | +| `/actions/{conversation_id}/{conversation_domain}` | Receive workflow-authored GitHub Actions text. | -### GitHub Actions notifications +### Repository webhook notifications + +The existing webhook endpoint provides centralized notifications rendered by this service. It supports +these GitHub Actions-related repository webhook events: + +| GitHub event | Supported action | Notification | +|--------------|------------------|--------------| +| Workflow runs (`workflow_run`) | `completed` | One summary for the completed workflow run. | +| Workflow jobs (`workflow_job`) | `completed` | One notification for each completed job in the workflow run. | + +All completed conclusions are reported, including successful, failed, cancelled, skipped, and timed-out +results when GitHub provides them. Other actions, such as `requested`, `queued`, and `in_progress`, are +accepted by the endpoint but do not produce a Wire message. + +Use `/github webhook help` in the Wire conversation for its URL, secret, and setup instructions. + +### Workflow-authored GitHub Actions notifications Use the reusable composite action to send workflow-composed text to a Wire conversation. The endpoint authenticates with the conversation secret already configured for GitHub webhooks. Store it as a GitHub Actions secret and do not print it in workflow logs. Empty or whitespace-only messages are rejected with `400`; a missing/invalid secret is rejected with `403`. +Use `/github actions help` in the Wire conversation to obtain the complete endpoint URL and shared +conversation secret. Store them as `WIRE_WEBHOOK_URL` and `WIRE_WEBHOOK_SECRET` respectively. + +The action accepts these inputs: + +| Input | Required | Description | +|-------|----------|-------------| +| `webhook-url` | Yes | Complete `/actions/{conversation_id}/{conversation_domain}` URL. | +| `secret` | Yes | Shared conversation secret used as the bearer token. | +| `text` | Yes | Complete message text to send without service-side formatting. | + ```yaml - name: Notify Wire on failure if: failure() || cancelled() uses: wireapp/github-app/.github/actions/notify-wire@v1 with: - endpoint: https://github-app.example.com - conversation-id: ${{ vars.WIRE_CONVERSATION_ID }} - conversation-domain: ${{ vars.WIRE_CONVERSATION_DOMAIN }} - secret: ${{ secrets.WIRE_CONVERSATION_SECRET }} + webhook-url: ${{ secrets.WIRE_WEBHOOK_URL }} + secret: ${{ secrets.WIRE_WEBHOOK_SECRET }} text: | CI failed for ${{ github.repository }} on `${{ github.ref_name }}`. Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} @@ -49,18 +74,41 @@ Use the reusable composite action to send workflow-composed text to a Wire conve The action fails the workflow if the notification request fails; callers can opt into `continue-on-error: true` when notification delivery should not affect the job result. Pin the action to a released tag or commit SHA. -### GitHub Actions notifications +The workflow owns the notification condition and complete message text. For example, use `if: always()` +for an unconditional notification or `if: failure() || cancelled()` for failure-only notification. -The existing webhook endpoint supports centralized notifications for these GitHub Actions events: +The endpoint returns: -| GitHub event | Supported action | Notification | -|--------------|------------------|--------------| -| Workflow runs (`workflow_run`) | `completed` | One summary for the completed workflow run. | -| Workflow jobs (`workflow_job`) | `completed` | One notification for each completed job in the workflow run. | +- `200 OK` after Wire accepts the message; +- `400 Bad Request` for an invalid conversation ID, malformed JSON, or blank text; +- `401 Unauthorized` for missing or malformed bearer authorization; +- `403 Forbidden` for an invalid token or a conversation without a stored token; +- `415 Unsupported Media Type` when the request is not JSON. -All completed conclusions are reported, including successful, failed, cancelled, skipped, and timed-out -results when GitHub provides them. Other actions, such as `requested`, `queued`, and `in_progress`, are -accepted by the endpoint but do not produce a Wire message. +### Wire commands + +| Command | Description | +|---------|-------------| +| `/github help` | List available commands and notification approaches. | +| `/github token` | Show or create the shared conversation token. | +| `/github webhook help` | Show repository webhook setup instructions. | +| `/github actions help` | Show the Actions endpoint, token usage, and reusable action example. | + +### Conversation secret storage + +The service stores one generated secret per qualified Wire conversation in Redis. The repository webhook +endpoint uses it for GitHub HMAC validation, while the Actions endpoint uses the same value as a bearer +token. `/github token`, `/github webhook help`, and `/github actions help` return the existing value or +create and store one when the conversation does not have one yet. + +Treat this secret as a credential: anyone with the Actions endpoint and secret can post messages to the +conversation. Keep it in GitHub Actions secrets and avoid exposing it in workflow output. + +### Reusable action releases + +Publish immutable semantic-version tags such as `v1.0.0` for the reusable action. A moving `v1` tag may +also point to the latest compatible v1 release. Consumers that require reproducible workflows should pin +the action to an immutable version tag or commit SHA. ## Building & Running diff --git a/src/main/kotlin/com/wire/github/EventsHandler.kt b/src/main/kotlin/com/wire/github/EventsHandler.kt index 94be414..2729697 100644 --- a/src/main/kotlin/com/wire/github/EventsHandler.kt +++ b/src/main/kotlin/com/wire/github/EventsHandler.kt @@ -1,13 +1,9 @@ package com.wire.github import com.wire.github.metrics.UsageMetrics -import com.wire.github.util.ENV_VAR_HOST -import com.wire.github.util.SessionIdentifierGenerator -import com.wire.github.util.toStorageKey import com.wire.sdk.WireEventsHandlerSuspending import com.wire.sdk.model.Conversation import com.wire.sdk.model.ConversationMember -import com.wire.sdk.model.QualifiedId import com.wire.sdk.model.WireMessage import io.lettuce.core.api.StatefulRedisConnection import org.koin.core.context.GlobalContext @@ -16,33 +12,34 @@ import org.slf4j.LoggerFactory class EventsHandler : WireEventsHandlerSuspending() { private val logger = LoggerFactory.getLogger(this::class.java) private val redisConnection = GlobalContext.get().get>() - private val storage = redisConnection.sync() + private val commandHandler = GitHubCommandHandler(storage = redisConnection.sync()) private val usageMetrics = GlobalContext.get().get() override suspend fun onTextMessageReceived(wireMessage: WireMessage.Text) { - if (wireMessage.text.equals(HELP_COMMAND, ignoreCase = true)) { - logger.info( - "Event received. Event: TextMessageReceived (HELP command), " + - "conversationId: ${wireMessage.conversationId}, " + - "senderId: ${wireMessage.sender}" - ) + val response = commandHandler.response( + command = wireMessage.text, + conversationId = wireMessage.conversationId + ) ?: return + + logger.info( + "Event received. Event: TextMessageReceived (GitHub command), " + + "conversationId: ${wireMessage.conversationId}, " + + "senderId: ${wireMessage.sender}" + ) + if (!wireMessage.text.equals(GitHubCommandHandler.TOKEN_COMMAND, ignoreCase = true)) { usageMetrics.onHelpCommand() - val message = formatSetupInstructions( - conversationId = wireMessage.conversationId, - secret = storage.get(wireMessage.conversationId.toStorageKey()) - ) + } - manager.sendMessage( - message = WireMessage.Text.create( - conversationId = wireMessage.conversationId, - text = message - ) - ) - logger.info( - "Event is processed successfully. Event: TextMessageReceived (HELP command), " + - "conversationId: ${wireMessage.conversationId}" + manager.sendMessage( + message = WireMessage.Text.create( + conversationId = wireMessage.conversationId, + text = response ) - } + ) + logger.info( + "Event is processed successfully. Event: TextMessageReceived (GitHub command), " + + "conversationId: ${wireMessage.conversationId}" + ) } override suspend fun onAppAddedToConversation( @@ -54,11 +51,12 @@ class EventsHandler : WireEventsHandlerSuspending() { "conversationId: ${conversation.id}" ) usageMetrics.onAppAddedToConversation() + val webhookSetup = commandHandler.webhookHelp(conversationId = conversation.id) val message = buildString { appendLine(WELCOME_TEXT) - appendLine(formatSetupInstructions(conversationId = conversation.id)) + appendLine(webhookSetup) appendLine() - append("Use the `$HELP_COMMAND` command to see the usage again.") + append("Use `${GitHubCommandHandler.HELP_COMMAND}` to see all available commands.") } manager.sendMessage( @@ -73,48 +71,10 @@ class EventsHandler : WireEventsHandlerSuspending() { ) } - private fun formatSetupInstructions( - conversationId: QualifiedId, - secret: String? = null - ): String { - val generatedSecret = secret ?: run { - val generated = SessionIdentifierGenerator.generate() - storage.set(conversationId.toStorageKey(), generated) - generated - } - - val url = String.format( - HOST_URL_PATTERN, - ENV_VAR_HOST, - conversationId.id, - conversationId.domain - ) - - val setupInstructions = String.format( - "Here is how to set me up:\n\n" + - "1. Go to the repository that you would like to connect to\n" + - "2. Go to **Settings / Webhooks / Add webhook**\n" + - "3. Add **Payload URL**: %s\n" + - "4. Set **Content-Type**: application/json\n" + - "5. Set **Secret**: %s\n" + - "6. Select the repository events you want to receive\n" + - "7. For GitHub Actions notifications, select **Workflow runs** and " + - "**Workflow jobs**. Completed results will be sent to this conversation.", - url, - generatedSecret - ) - - return setupInstructions - } - private companion object { - const val HOST_URL_PATTERN = "%s/%s/%s" - const val WELCOME_TEXT = "👋 Hi, I'm GitHub App. Thanks for adding me to the conversation.\n" + "You can use me to receive GitHub notifications in Wire.\n" + "I'm here to help make everyday work a little easier." - - const val HELP_COMMAND = "/github help" } } diff --git a/src/main/kotlin/com/wire/github/GitHubCommandHandler.kt b/src/main/kotlin/com/wire/github/GitHubCommandHandler.kt new file mode 100644 index 0000000..41c7c04 --- /dev/null +++ b/src/main/kotlin/com/wire/github/GitHubCommandHandler.kt @@ -0,0 +1,78 @@ +package com.wire.github + +import com.wire.github.util.ENV_VAR_HOST +import com.wire.github.util.SessionIdentifierGenerator +import com.wire.github.util.toStorageKey +import com.wire.sdk.model.QualifiedId +import io.lettuce.core.api.sync.RedisCommands + +internal class GitHubCommandHandler( + private val storage: RedisCommands, + private val host: String = ENV_VAR_HOST, + private val generateSecret: () -> String = SessionIdentifierGenerator::generate +) { + fun response( + command: String, + conversationId: QualifiedId + ): String? = + when { + command.equals(HELP_COMMAND, ignoreCase = true) -> help() + command.equals(TOKEN_COMMAND, ignoreCase = true) -> token(conversationId) + command.equals(WEBHOOK_HELP_COMMAND, ignoreCase = true) -> webhookHelp(conversationId) + command.equals(ACTIONS_HELP_COMMAND, ignoreCase = true) -> actionsHelp(conversationId) + else -> null + } + + fun help(): String = + "GitHub App supports repository webhooks and workflow-authored Actions notifications.\n\n" + + "Available commands:\n" + + "- `$TOKEN_COMMAND` — show or create this conversation's token\n" + + "- `$WEBHOOK_HELP_COMMAND` — configure GitHub repository webhooks\n" + + "- `$ACTIONS_HELP_COMMAND` — send custom messages from GitHub Actions" + + private fun token(conversationId: QualifiedId): String = + "Conversation token:\n\n`${getOrCreateSecret(conversationId)}`\n\n" + + "Store this value as a secret. Do not print it in workflow logs." + + fun webhookHelp(conversationId: QualifiedId): String { + val webhookUrl = "$normalizedHost/${conversationId.id}/${conversationId.domain}" + return "GitHub repository webhook setup:\n\n" + + "1. Open **Settings / Webhooks / Add webhook** in the repository\n" + + "2. Set **Payload URL**: $webhookUrl\n" + + "3. Set **Content-Type**: application/json\n" + + "4. Set **Secret**: ${getOrCreateSecret(conversationId)}\n" + + "5. Select the repository events to receive\n" + + "6. For centralized Actions notifications, select **Workflow runs** and " + + "**Workflow jobs**" + } + + private fun actionsHelp(conversationId: QualifiedId): String { + val actionsUrl = "$normalizedHost/actions/${conversationId.id}/${conversationId.domain}" + return "GitHub Actions notification setup:\n\n" + + "Store this URL as `WIRE_WEBHOOK_URL`:\n$actionsUrl\n\n" + + "Store this token as `WIRE_WEBHOOK_SECRET`:\n${getOrCreateSecret(conversationId)}\n\n" + + "Use the reusable action:\n\n" + + "```yaml\n" + + "uses: wireapp/github-app/.github/actions/notify-wire@v1\n" + + "with:\n" + + " webhook-url: \${{ secrets.WIRE_WEBHOOK_URL }}\n" + + " secret: \${{ secrets.WIRE_WEBHOOK_SECRET }}\n" + + " text: Message to send to Wire\n" + + "```" + } + + private fun getOrCreateSecret(conversationId: QualifiedId): String { + val storageKey = conversationId.toStorageKey() + return storage.get(storageKey) ?: generateSecret().also { storage.set(storageKey, it) } + } + + private val normalizedHost: String + get() = host.trimEnd('/') + + companion object { + const val HELP_COMMAND = "/github help" + const val TOKEN_COMMAND = "/github token" + const val WEBHOOK_HELP_COMMAND = "/github webhook help" + const val ACTIONS_HELP_COMMAND = "/github actions help" + } +} diff --git a/src/main/kotlin/com/wire/github/Routing.kt b/src/main/kotlin/com/wire/github/Routing.kt index 772fa6e..66da1d8 100644 --- a/src/main/kotlin/com/wire/github/Routing.kt +++ b/src/main/kotlin/com/wire/github/Routing.kt @@ -1,18 +1,23 @@ package com.wire.github import com.wire.github.metrics.UsageMetrics +import com.wire.github.request.model.ActionsNotificationRequest import com.wire.github.response.model.GitHubResponse +import com.wire.github.util.ActionsTokenValidator import com.wire.github.util.KtxSerializer import com.wire.github.util.SignatureValidator import com.wire.github.util.TemplateHandler import com.wire.sdk.WireAppSdk import com.wire.sdk.model.QualifiedId import com.wire.sdk.model.WireMessage +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.serialization.kotlinx.json.json import io.ktor.server.application.Application import io.ktor.server.application.install import io.ktor.server.application.log +import io.ktor.server.application.ApplicationCall import io.ktor.server.plugins.contentnegotiation.ContentNegotiation import io.ktor.server.request.receiveText import io.ktor.server.response.respond @@ -20,12 +25,12 @@ import io.ktor.server.response.respondText import io.ktor.server.routing.application import io.ktor.server.routing.get import io.ktor.server.routing.post +import io.ktor.server.routing.Route import io.ktor.server.routing.routing import java.io.IOException import java.util.UUID import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerializationException -import kotlinx.serialization.Serializable import org.koin.core.context.GlobalContext @Suppress("LongMethod") @@ -36,6 +41,7 @@ fun Application.configureRouting() { } val wireAppSdk = GlobalContext.get().get() + val actionsTokenValidator = GlobalContext.get().get() val signatureValidator = GlobalContext.get().get() val templateHandler = GlobalContext.get().get() val usageMetrics = GlobalContext.get().get() @@ -145,51 +151,85 @@ fun Application.configureRouting() { return@post call.response.status(HttpStatusCode.OK) } - post("/actions/{$PARAM_CONVERSATION_ID}/{$PARAM_CONVERSATION_DOMAIN}") { - val conversationId = call.parameters[PARAM_CONVERSATION_ID] - ?: return@post call.respond(HttpStatusCode.BadRequest) - val conversationDomain = call.parameters[PARAM_CONVERSATION_DOMAIN] - ?: return@post call.respond(HttpStatusCode.BadRequest) - val bearerToken = call.request.headers["Authorization"] - ?.takeIf { it.startsWith("Bearer ") } - ?.removePrefix("Bearer ") - ?.takeIf { it.isNotBlank() } - ?: return@post call.respond(HttpStatusCode.Forbidden) - - val isAuthorized = try { - signatureValidator.isBearerTokenValid(conversationId, conversationDomain, bearerToken) - } catch (exception: IOException) { - application.log.warn("No secret stored for conversation $conversationId@$conversationDomain") - false - } - if (!isAuthorized) return@post call.respond(HttpStatusCode.Forbidden) + configureActionsRoute( + wireAppSdk = wireAppSdk, + actionsTokenValidator = actionsTokenValidator + ) + } +} - val request = try { - KtxSerializer.json.decodeFromString(call.receiveText()) - } catch (exception: SerializationException) { - return@post call.respond(HttpStatusCode.BadRequest) - } - if (request.text.isBlank()) return@post call.respond(HttpStatusCode.BadRequest) +private const val PARAM_CONVERSATION_ID = "conversationId" +private const val PARAM_CONVERSATION_DOMAIN = "conversationDomain" - val conversationUuid = try { - UUID.fromString(conversationId) - } catch (exception: IllegalArgumentException) { - return@post call.respond(HttpStatusCode.BadRequest) - } +private fun Route.configureActionsRoute( + wireAppSdk: WireAppSdk, + actionsTokenValidator: ActionsTokenValidator +) { + post("/actions/{$PARAM_CONVERSATION_ID}/{$PARAM_CONVERSATION_DOMAIN}") { + val conversationId = call.parameters[PARAM_CONVERSATION_ID] + ?: return@post call.response.status(HttpStatusCode.BadRequest) + val conversationDomain = call.parameters[PARAM_CONVERSATION_DOMAIN] + ?: return@post call.response.status(HttpStatusCode.BadRequest) + val conversationUuid = runCatching { UUID.fromString(conversationId) }.getOrNull() + ?: return@post call.response.status(HttpStatusCode.BadRequest) + + if (!call.hasJsonContentType()) { + return@post call.response.status(HttpStatusCode.UnsupportedMediaType) + } - wireAppSdk.getApplicationManager().sendMessage( - message = WireMessage.Text.create( - conversationId = QualifiedId(id = conversationUuid, domain = conversationDomain), - text = request.text - ) + val bearerToken = call.request.headers[HttpHeaders.Authorization].toBearerToken() + ?: return@post call.response.status(HttpStatusCode.Unauthorized) + + if (!actionsTokenValidator.isValid(conversationId, conversationDomain, bearerToken)) { + application.log.warn( + "Invalid Actions token for conversation $conversationId@$conversationDomain" ) - call.respond(HttpStatusCode.OK) + return@post call.response.status(HttpStatusCode.Forbidden) } + + val request = try { + KtxSerializer.json.decodeFromString(call.receiveText()) + } catch (exception: SerializationException) { + application.log.debug( + "Invalid Actions payload for conversation $conversationId@$conversationDomain", + exception + ) + return@post call.response.status(HttpStatusCode.BadRequest) + } + + if (request.text.isBlank()) return@post call.response.status(HttpStatusCode.BadRequest) + + wireAppSdk.getApplicationManager().sendMessage( + message = WireMessage.Text.create( + conversationId = QualifiedId( + id = conversationUuid, + domain = conversationDomain + ), + text = request.text + ) + ) + call.response.status(HttpStatusCode.OK) } } -private const val PARAM_CONVERSATION_ID = "conversationId" -private const val PARAM_CONVERSATION_DOMAIN = "conversationDomain" - -@Serializable -private data class ActionsMessageRequest(val text: String) +private fun ApplicationCall.hasJsonContentType(): Boolean = + request.headers[HttpHeaders.ContentType] + ?.substringBefore(';') + ?.trim() + ?.equals(ContentType.Application.Json.toString(), ignoreCase = true) == true + +private fun String?.toBearerToken(): String? { + val value = this?.trim() + val separator = value?.indexOf(' ') ?: -1 + return if (value != null && separator > 0) { + val scheme = value.substring(startIndex = 0, endIndex = separator) + val token = value.substring(startIndex = separator + 1).trim() + token.takeIf { + scheme.equals("Bearer", ignoreCase = true) && + it.isNotEmpty() && + it.none(Char::isWhitespace) + } + } else { + null + } +} diff --git a/src/main/kotlin/com/wire/github/config/Modules.kt b/src/main/kotlin/com/wire/github/config/Modules.kt index de9b29b..475e79d 100644 --- a/src/main/kotlin/com/wire/github/config/Modules.kt +++ b/src/main/kotlin/com/wire/github/config/Modules.kt @@ -2,6 +2,7 @@ package com.wire.github.config import com.wire.github.EventsHandler import com.wire.github.metrics.UsageMetrics +import com.wire.github.util.ActionsTokenValidator import com.wire.github.util.ENV_VAR_API_HOST import com.wire.github.util.ENV_VAR_API_TOKEN import com.wire.github.util.ENV_VAR_APPLICATION_ID @@ -24,6 +25,7 @@ val projectModules = module { } } single { SignatureValidator() } + single { ActionsTokenValidator(redisConnection = get()) } single { TemplateHandler() } single { RedisClient.create(ENV_VAR_REDIS_URL) } single> { get().connect() } diff --git a/src/main/kotlin/com/wire/github/request/model/ActionsNotificationRequest.kt b/src/main/kotlin/com/wire/github/request/model/ActionsNotificationRequest.kt new file mode 100644 index 0000000..f31011c --- /dev/null +++ b/src/main/kotlin/com/wire/github/request/model/ActionsNotificationRequest.kt @@ -0,0 +1,8 @@ +package com.wire.github.request.model + +import kotlinx.serialization.Serializable + +@Serializable +data class ActionsNotificationRequest( + val text: String +) diff --git a/src/main/kotlin/com/wire/github/util/ActionsTokenValidator.kt b/src/main/kotlin/com/wire/github/util/ActionsTokenValidator.kt new file mode 100644 index 0000000..40a2acc --- /dev/null +++ b/src/main/kotlin/com/wire/github/util/ActionsTokenValidator.kt @@ -0,0 +1,24 @@ +package com.wire.github.util + +import io.lettuce.core.api.StatefulRedisConnection +import java.nio.charset.StandardCharsets +import java.security.MessageDigest + +class ActionsTokenValidator( + redisConnection: StatefulRedisConnection +) { + private val storage = redisConnection.sync() + + fun isValid( + conversationId: String, + conversationDomain: String, + token: String + ): Boolean { + val storageKey = conversationId.toStorageKey(domain = conversationDomain) + val secret = storage.get(storageKey) ?: return false + return MessageDigest.isEqual( + secret.toByteArray(StandardCharsets.UTF_8), + token.toByteArray(StandardCharsets.UTF_8) + ) + } +} diff --git a/src/main/kotlin/com/wire/github/util/SignatureValidator.kt b/src/main/kotlin/com/wire/github/util/SignatureValidator.kt index 7cd2f5b..792b73f 100644 --- a/src/main/kotlin/com/wire/github/util/SignatureValidator.kt +++ b/src/main/kotlin/com/wire/github/util/SignatureValidator.kt @@ -3,7 +3,6 @@ package com.wire.github.util import io.lettuce.core.api.StatefulRedisConnection import java.io.IOException import java.util.Locale -import java.security.MessageDigest import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec import org.koin.core.context.GlobalContext @@ -12,16 +11,6 @@ class SignatureValidator { private val redisConnection = GlobalContext.get().get>() private val storage = redisConnection.sync() - fun isBearerTokenValid( - conversationId: String, - conversationDomain: String, - token: String - ): Boolean { - val secret = storage.get(conversationId.toStorageKey(domain = conversationDomain)) - ?: throw IOException("Missing secret for Conversation: $conversationId") - return MessageDigest.isEqual(secret.toByteArray(), token.toByteArray()) - } - @Throws(Exception::class) fun isValid( conversationId: String, diff --git a/src/test/kotlin/com/wire/github/ApplicationTest.kt b/src/test/kotlin/com/wire/github/ApplicationTest.kt index d9c92e4..821022b 100644 --- a/src/test/kotlin/com/wire/github/ApplicationTest.kt +++ b/src/test/kotlin/com/wire/github/ApplicationTest.kt @@ -1,10 +1,12 @@ package com.wire.github import com.wire.github.metrics.UsageMetrics +import com.wire.github.util.ActionsTokenValidator import com.wire.github.util.SignatureValidator import com.wire.github.util.TemplateHandler import com.wire.sdk.WireAppSdk import com.wire.sdk.model.QualifiedId +import com.wire.sdk.model.WireMessage import io.ktor.client.request.get import io.ktor.client.request.post import io.ktor.client.request.setBody @@ -24,6 +26,7 @@ import io.micrometer.prometheusmetrics.PrometheusConfig import io.micrometer.prometheusmetrics.PrometheusMeterRegistry import io.mockk.every import io.mockk.mockk +import io.mockk.slot import io.mockk.verify import java.util.UUID import kotlin.test.AfterTest @@ -49,6 +52,7 @@ class ApplicationTest { modules( module { single { SignatureValidator() } + single { ActionsTokenValidator(redisConnection = mockRedisConnection) } single { TemplateHandler() } single { mockRedisClient } single> { mockRedisConnection } @@ -290,6 +294,64 @@ class ApplicationTest { } } + @Test + fun `given invalid signature, when webhook is posted, then forbidden without sending`() { + val signatureValidator = mockk() + every { signatureValidator.isValid(any(), any(), any(), any()) } returns false + val wireAppSdk = mockk(relaxed = true) + loadKoinModules( + module { + single { signatureValidator } + single { wireAppSdk } + } + ) + + testApplication { + application { configureRouting() } + val response = client.post("/${CONVERSATION_ID.id}/${CONVERSATION_ID.domain}") { + contentType(ContentType.Application.Json) + header("X-GitHub-Event", DUMMY_EVENT) + header("X-Hub-Signature", "sha1=wrong-signature") + header("X-GitHub-Delivery", "delivery") + setBody(DUMMY_PAYLOAD) + } + + assertEquals(HttpStatusCode.Forbidden, response.status) + verify(exactly = 0) { + wireAppSdk.getApplicationManager().sendMessage(message = any()) + } + } + } + + @Test + fun `given malformed payload, when webhook is posted, then bad request without sending`() { + val signatureValidator = mockk() + every { signatureValidator.isValid(any(), any(), any(), any()) } returns true + val wireAppSdk = mockk(relaxed = true) + loadKoinModules( + module { + single { signatureValidator } + single { wireAppSdk } + } + ) + + testApplication { + application { configureRouting() } + val response = client.post("/${CONVERSATION_ID.id}/${CONVERSATION_ID.domain}") { + contentType(ContentType.Application.Json) + header("X-GitHub-Event", DUMMY_EVENT) + header("X-Hub-Signature", "sha1=$DUMMY_SIGNATURE") + header("X-GitHub-Delivery", "delivery") + setBody("{") + } + + assertEquals(HttpStatusCode.BadRequest, response.status) + verify(exactly = 0) { + wireAppSdk.getApplicationManager().sendMessage(message = any()) + } + } + } + @Test fun `given completed GitHub Actions events, when received, then messages are sent`() { val signatureValidator = mockk() @@ -332,19 +394,11 @@ class ApplicationTest { @Test fun `given valid bearer secret, when Actions message is posted, then message is sent`() { - val signatureValidator = mockk() + val (actionsTokenValidator, wireAppSdk) = loadActionsDependencies(tokenValid = true) + val sentMessage = slot() every { - signatureValidator.isBearerTokenValid( - CONVERSATION_ID.id.toString(), CONVERSATION_ID.domain, "conversation-secret" - ) - } returns true - val wireAppSdk = mockk(relaxed = true) - loadKoinModules( - module { - single { signatureValidator } - single { wireAppSdk } - } - ) + wireAppSdk.getApplicationManager().sendMessage(message = capture(sentMessage)) + } returns UUID.randomUUID() testApplication { application { configureRouting() } @@ -355,10 +409,142 @@ class ApplicationTest { } assertEquals(HttpStatusCode.OK, response.status) + assertEquals("", response.bodyAsText()) + assertEquals(CONVERSATION_ID, sentMessage.captured.conversationId) + assertEquals("CI failed", sentMessage.captured.text) + verify(exactly = 1) { + actionsTokenValidator.isValid( + CONVERSATION_ID.id.toString(), + CONVERSATION_ID.domain, + "conversation-secret" + ) + } verify(exactly = 1) { wireAppSdk.getApplicationManager().sendMessage(message = any()) } } } + @Test + fun `Actions message without bearer authorization is unauthorized`() { + listOf( + null, + "Basic conversation-secret", + "Bearer ", + "Bearer token with-spaces" + ).forEach { authorization -> + val (actionsTokenValidator, wireAppSdk) = loadActionsDependencies(tokenValid = true) + + testApplication { + application { configureRouting() } + val response = client.post( + "/actions/${CONVERSATION_ID.id}/${CONVERSATION_ID.domain}" + ) { + contentType(ContentType.Application.Json) + authorization?.let { header("Authorization", it) } + setBody("""{"text":"CI failed"}""") + } + + assertEquals(HttpStatusCode.Unauthorized, response.status) + verify(exactly = 0) { actionsTokenValidator.isValid(any(), any(), any()) } + verify(exactly = 0) { + wireAppSdk.getApplicationManager().sendMessage(message = any()) + } + } + } + } + + @Test + fun `given wrong bearer token, when Actions message is posted, then forbidden`() { + val (_, wireAppSdk) = loadActionsDependencies(tokenValid = false) + + testApplication { + application { configureRouting() } + val response = client.postActionsMessage() + + assertEquals(HttpStatusCode.Forbidden, response.status) + verify(exactly = 0) { wireAppSdk.getApplicationManager().sendMessage(message = any()) } + } + } + + @Test + fun `Actions message with invalid text payload is a bad request`() { + listOf("{", "{}", """{"body":"CI failed"}""", """{"text":" "}""").forEach { payload -> + val (_, wireAppSdk) = loadActionsDependencies(tokenValid = true) + + testApplication { + application { configureRouting() } + val response = client.postActionsMessage(payload = payload) + + assertEquals(HttpStatusCode.BadRequest, response.status) + verify(exactly = 0) { + wireAppSdk.getApplicationManager().sendMessage(message = any()) + } + } + } + } + + @Test + fun `Actions message with non-json content type is unsupported`() { + val (actionsTokenValidator, wireAppSdk) = loadActionsDependencies(tokenValid = true) + + testApplication { + application { configureRouting() } + val response = client.post( + "/actions/${CONVERSATION_ID.id}/${CONVERSATION_ID.domain}" + ) { + contentType(ContentType.Text.Plain) + header("Authorization", "Bearer conversation-secret") + setBody("""{"text":"CI failed"}""") + } + + assertEquals(HttpStatusCode.UnsupportedMediaType, response.status) + verify(exactly = 0) { actionsTokenValidator.isValid(any(), any(), any()) } + verify(exactly = 0) { + wireAppSdk.getApplicationManager().sendMessage(message = any()) + } + } + } + + @Test + fun `given invalid conversation ID, when Actions message is posted, then bad request`() { + val (actionsTokenValidator, wireAppSdk) = loadActionsDependencies(tokenValid = true) + + testApplication { + application { configureRouting() } + val response = client.post("/actions/not-a-uuid/${CONVERSATION_ID.domain}") { + contentType(ContentType.Application.Json) + header("Authorization", "Bearer conversation-secret") + setBody("""{"text":"CI failed"}""") + } + + assertEquals(HttpStatusCode.BadRequest, response.status) + verify(exactly = 0) { actionsTokenValidator.isValid(any(), any(), any()) } + verify(exactly = 0) { wireAppSdk.getApplicationManager().sendMessage(message = any()) } + } + } + + private fun loadActionsDependencies( + tokenValid: Boolean + ): Pair { + val actionsTokenValidator = mockk() + every { actionsTokenValidator.isValid(any(), any(), any()) } returns tokenValid + val wireAppSdk = mockk(relaxed = true) + loadKoinModules( + module { + single { actionsTokenValidator } + single { wireAppSdk } + } + ) + return actionsTokenValidator to wireAppSdk + } + + private suspend fun io.ktor.client.HttpClient.postActionsMessage( + payload: String = """{"text":"CI failed"}""" + ) = post("/actions/${CONVERSATION_ID.id}/${CONVERSATION_ID.domain}") { + contentType(ContentType.Application.Json) + header("Authorization", "Bearer conversation-secret") + setBody(payload) + } + private companion object { val CONVERSATION_ID = QualifiedId( id = UUID.randomUUID(), diff --git a/src/test/kotlin/com/wire/github/GitHubCommandHandlerTest.kt b/src/test/kotlin/com/wire/github/GitHubCommandHandlerTest.kt new file mode 100644 index 0000000..614bcd5 --- /dev/null +++ b/src/test/kotlin/com/wire/github/GitHubCommandHandlerTest.kt @@ -0,0 +1,90 @@ +package com.wire.github + +import com.wire.github.util.toStorageKey +import com.wire.sdk.model.QualifiedId +import io.lettuce.core.api.sync.RedisCommands +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertNull + +class GitHubCommandHandlerTest { + private val storage = mockk>() + private val handler = GitHubCommandHandler( + storage = storage, + host = "https://github-app.example.com/", + generateSecret = { GENERATED_SECRET } + ) + + @Test + fun `help lists all available commands`() { + val response = handler.response(GitHubCommandHandler.HELP_COMMAND, CONVERSATION_ID) + + assertContains(response.orEmpty(), GitHubCommandHandler.TOKEN_COMMAND) + assertContains(response.orEmpty(), GitHubCommandHandler.WEBHOOK_HELP_COMMAND) + assertContains(response.orEmpty(), GitHubCommandHandler.ACTIONS_HELP_COMMAND) + } + + @Test + fun `token returns existing conversation token`() { + every { storage.get(CONVERSATION_ID.toStorageKey()) } returns EXISTING_SECRET + + val response = handler.response(GitHubCommandHandler.TOKEN_COMMAND, CONVERSATION_ID) + + assertContains(response.orEmpty(), EXISTING_SECRET) + verify(exactly = 0) { storage.set(any(), any()) } + } + + @Test + fun `token creates and stores conversation token when one is missing`() { + every { storage.get(CONVERSATION_ID.toStorageKey()) } returns null + every { storage.set(CONVERSATION_ID.toStorageKey(), GENERATED_SECRET) } returns "OK" + + val response = handler.response(GitHubCommandHandler.TOKEN_COMMAND, CONVERSATION_ID) + + assertContains(response.orEmpty(), GENERATED_SECRET) + verify(exactly = 1) { storage.set(CONVERSATION_ID.toStorageKey(), GENERATED_SECRET) } + } + + @Test + fun `webhook help includes webhook URL and token`() { + every { storage.get(CONVERSATION_ID.toStorageKey()) } returns EXISTING_SECRET + + val response = handler.response(GitHubCommandHandler.WEBHOOK_HELP_COMMAND, CONVERSATION_ID) + + assertContains( + response.orEmpty(), + "https://github-app.example.com/${CONVERSATION_ID.id}/${CONVERSATION_ID.domain}" + ) + assertContains(response.orEmpty(), EXISTING_SECRET) + } + + @Test + fun `Actions help includes endpoint token and reusable action`() { + every { storage.get(CONVERSATION_ID.toStorageKey()) } returns EXISTING_SECRET + + val response = handler.response(GitHubCommandHandler.ACTIONS_HELP_COMMAND, CONVERSATION_ID) + + assertContains( + response.orEmpty(), + "https://github-app.example.com/actions/${CONVERSATION_ID.id}/${CONVERSATION_ID.domain}" + ) + assertContains(response.orEmpty(), EXISTING_SECRET) + assertContains(response.orEmpty(), "wireapp/github-app/.github/actions/notify-wire@v1") + assertContains(response.orEmpty(), "webhook-url") + } + + @Test + fun `unknown command is ignored`() { + assertNull(handler.response("/github unknown", CONVERSATION_ID)) + } + + private companion object { + val CONVERSATION_ID = QualifiedId(UUID.randomUUID(), "conversation.example.com") + const val EXISTING_SECRET = "existing-secret" + const val GENERATED_SECRET = "generated-secret" + } +} diff --git a/src/test/kotlin/com/wire/github/util/ActionsTokenValidatorTest.kt b/src/test/kotlin/com/wire/github/util/ActionsTokenValidatorTest.kt new file mode 100644 index 0000000..ceccc61 --- /dev/null +++ b/src/test/kotlin/com/wire/github/util/ActionsTokenValidatorTest.kt @@ -0,0 +1,47 @@ +package com.wire.github.util + +import io.lettuce.core.api.StatefulRedisConnection +import io.lettuce.core.api.sync.RedisCommands +import io.mockk.every +import io.mockk.mockk +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ActionsTokenValidatorTest { + private val storage = mockk>() + private val redisConnection = mockk>() + private val validator by lazy { ActionsTokenValidator(redisConnection) } + + init { + every { redisConnection.sync() } returns storage + } + + @Test + fun `matching stored token is valid`() { + every { storage.get(CONVERSATION_ID.toStorageKey(CONVERSATION_DOMAIN)) } returns SECRET + + assertTrue(validator.isValid(CONVERSATION_ID, CONVERSATION_DOMAIN, SECRET)) + } + + @Test + fun `wrong token is invalid`() { + every { storage.get(CONVERSATION_ID.toStorageKey(CONVERSATION_DOMAIN)) } returns SECRET + + assertFalse(validator.isValid(CONVERSATION_ID, CONVERSATION_DOMAIN, "wrong-secret")) + } + + @Test + fun `missing stored token is invalid`() { + every { storage.get(CONVERSATION_ID.toStorageKey(CONVERSATION_DOMAIN)) } returns null + + assertFalse(validator.isValid(CONVERSATION_ID, CONVERSATION_DOMAIN, SECRET)) + } + + private companion object { + val CONVERSATION_ID = UUID.randomUUID().toString() + const val CONVERSATION_DOMAIN = "conversation.example.com" + const val SECRET = "conversation-secret" + } +} From fcb6d06cd060e6135043b8a143fa8f67596addfe Mon Sep 17 00:00:00 2001 From: alexandreferris Date: Wed, 30 Sep 2026 10:26:40 +0200 Subject: [PATCH 03/12] feat: Add Github Actions workflow in webhook #WPB-28780 * Added concise error bodies for invalid UUID, content type, authorization, credentials, JSON, and blank text * Kept authentication errors generic * Kept successful `200` responses empty * Updated tests to assert each error message --- src/main/kotlin/com/wire/github/Routing.kt | 71 ++++++++++++++----- .../kotlin/com/wire/github/ApplicationTest.kt | 23 +++++- 2 files changed, 77 insertions(+), 17 deletions(-) diff --git a/src/main/kotlin/com/wire/github/Routing.kt b/src/main/kotlin/com/wire/github/Routing.kt index 66da1d8..4be34bd 100644 --- a/src/main/kotlin/com/wire/github/Routing.kt +++ b/src/main/kotlin/com/wire/github/Routing.kt @@ -167,37 +167,46 @@ private fun Route.configureActionsRoute( ) { post("/actions/{$PARAM_CONVERSATION_ID}/{$PARAM_CONVERSATION_DOMAIN}") { val conversationId = call.parameters[PARAM_CONVERSATION_ID] - ?: return@post call.response.status(HttpStatusCode.BadRequest) + ?: return@post call.respondActionsError( + HttpStatusCode.BadRequest, + "Missing conversation ID" + ) val conversationDomain = call.parameters[PARAM_CONVERSATION_DOMAIN] - ?: return@post call.response.status(HttpStatusCode.BadRequest) + ?: return@post call.respondActionsError( + HttpStatusCode.BadRequest, + "Missing conversation domain" + ) val conversationUuid = runCatching { UUID.fromString(conversationId) }.getOrNull() - ?: return@post call.response.status(HttpStatusCode.BadRequest) + ?: return@post call.respondActionsError( + HttpStatusCode.BadRequest, + "Invalid conversation ID" + ) if (!call.hasJsonContentType()) { - return@post call.response.status(HttpStatusCode.UnsupportedMediaType) + return@post call.respondActionsError( + HttpStatusCode.UnsupportedMediaType, + "Content-Type must be application/json" + ) } val bearerToken = call.request.headers[HttpHeaders.Authorization].toBearerToken() - ?: return@post call.response.status(HttpStatusCode.Unauthorized) + ?: return@post call.respondActionsError( + HttpStatusCode.Unauthorized, + "Bearer authorization is required" + ) if (!actionsTokenValidator.isValid(conversationId, conversationDomain, bearerToken)) { application.log.warn( "Invalid Actions token for conversation $conversationId@$conversationDomain" ) - return@post call.response.status(HttpStatusCode.Forbidden) - } - - val request = try { - KtxSerializer.json.decodeFromString(call.receiveText()) - } catch (exception: SerializationException) { - application.log.debug( - "Invalid Actions payload for conversation $conversationId@$conversationDomain", - exception + return@post call.respondActionsError( + HttpStatusCode.Forbidden, + "Invalid conversation credentials" ) - return@post call.response.status(HttpStatusCode.BadRequest) } - if (request.text.isBlank()) return@post call.response.status(HttpStatusCode.BadRequest) + val request = call.receiveActionsRequest(conversationId, conversationDomain) + ?: return@post wireAppSdk.getApplicationManager().sendMessage( message = WireMessage.Text.create( @@ -212,6 +221,36 @@ private fun Route.configureActionsRoute( } } +private suspend fun ApplicationCall.receiveActionsRequest( + conversationId: String, + conversationDomain: String +): ActionsNotificationRequest? { + val request = try { + KtxSerializer.json.decodeFromString(receiveText()) + } catch (exception: SerializationException) { + application.log.debug( + "Invalid Actions payload for conversation $conversationId@$conversationDomain", + exception + ) + respondActionsError(HttpStatusCode.BadRequest, "Invalid JSON payload") + return null + } + + return if (request.text.isBlank()) { + respondActionsError(HttpStatusCode.BadRequest, "Text must not be blank") + null + } else { + request + } +} + +private suspend fun ApplicationCall.respondActionsError( + status: HttpStatusCode, + message: String +) { + respondText(text = message, status = status) +} + private fun ApplicationCall.hasJsonContentType(): Boolean = request.headers[HttpHeaders.ContentType] ?.substringBefore(';') diff --git a/src/test/kotlin/com/wire/github/ApplicationTest.kt b/src/test/kotlin/com/wire/github/ApplicationTest.kt index 821022b..ce4e5cd 100644 --- a/src/test/kotlin/com/wire/github/ApplicationTest.kt +++ b/src/test/kotlin/com/wire/github/ApplicationTest.kt @@ -444,6 +444,7 @@ class ApplicationTest { } assertEquals(HttpStatusCode.Unauthorized, response.status) + assertEquals("Bearer authorization is required", response.bodyAsText()) verify(exactly = 0) { actionsTokenValidator.isValid(any(), any(), any()) } verify(exactly = 0) { wireAppSdk.getApplicationManager().sendMessage(message = any()) @@ -461,13 +462,14 @@ class ApplicationTest { val response = client.postActionsMessage() assertEquals(HttpStatusCode.Forbidden, response.status) + assertEquals("Invalid conversation credentials", response.bodyAsText()) verify(exactly = 0) { wireAppSdk.getApplicationManager().sendMessage(message = any()) } } } @Test fun `Actions message with invalid text payload is a bad request`() { - listOf("{", "{}", """{"body":"CI failed"}""", """{"text":" "}""").forEach { payload -> + listOf("{", "{}", """{"body":"CI failed"}""").forEach { payload -> val (_, wireAppSdk) = loadActionsDependencies(tokenValid = true) testApplication { @@ -475,6 +477,7 @@ class ApplicationTest { val response = client.postActionsMessage(payload = payload) assertEquals(HttpStatusCode.BadRequest, response.status) + assertEquals("Invalid JSON payload", response.bodyAsText()) verify(exactly = 0) { wireAppSdk.getApplicationManager().sendMessage(message = any()) } @@ -482,6 +485,22 @@ class ApplicationTest { } } + @Test + fun `Actions message with blank text is a bad request`() { + val (_, wireAppSdk) = loadActionsDependencies(tokenValid = true) + + testApplication { + application { configureRouting() } + val response = client.postActionsMessage(payload = """{"text":" "}""") + + assertEquals(HttpStatusCode.BadRequest, response.status) + assertEquals("Text must not be blank", response.bodyAsText()) + verify(exactly = 0) { + wireAppSdk.getApplicationManager().sendMessage(message = any()) + } + } + } + @Test fun `Actions message with non-json content type is unsupported`() { val (actionsTokenValidator, wireAppSdk) = loadActionsDependencies(tokenValid = true) @@ -497,6 +516,7 @@ class ApplicationTest { } assertEquals(HttpStatusCode.UnsupportedMediaType, response.status) + assertEquals("Content-Type must be application/json", response.bodyAsText()) verify(exactly = 0) { actionsTokenValidator.isValid(any(), any(), any()) } verify(exactly = 0) { wireAppSdk.getApplicationManager().sendMessage(message = any()) @@ -517,6 +537,7 @@ class ApplicationTest { } assertEquals(HttpStatusCode.BadRequest, response.status) + assertEquals("Invalid conversation ID", response.bodyAsText()) verify(exactly = 0) { actionsTokenValidator.isValid(any(), any(), any()) } verify(exactly = 0) { wireAppSdk.getApplicationManager().sendMessage(message = any()) } } From 1d4968cfdf551c1cd04c410b7f212d70e329f081 Mon Sep 17 00:00:00 2001 From: alexandreferris Date: Wed, 30 Sep 2026 10:40:05 +0200 Subject: [PATCH 04/12] feat: Add Github Custom Actions #WPB-28781 * Add dummy workflow in pull-request.yml to test custom action --- .github/workflows/pull-request.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index dd4eb06..a75cbdd 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -30,3 +30,24 @@ jobs: path: | **/build/reports/ **/build/test-results/ + + notify-wire-staging: + if: github.event_name == 'workflow_dispatch' + needs: tests + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Send test notification to Wire + uses: ./.github/actions/notify-wire + with: + webhook-url: ${{ secrets.WIRE_WEBHOOK_URL }} + secret: ${{ secrets.WIRE_WEBHOOK_SECRET }} + text: | + ✅ notify-wire staging test + Repository: ${{ github.repository }} + Ref: ${{ github.ref_name }} + Commit: ${{ github.sha }} + Actor: ${{ github.actor }} + Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} From de57452bfe6c7739373d0f1d952ebc33eaa8f12d Mon Sep 17 00:00:00 2001 From: alexandreferris Date: Wed, 30 Sep 2026 14:42:03 +0200 Subject: [PATCH 05/12] feat: Add Github Custom Actions #WPB-28781 * Generate and store a dedicated per-conversation Actions token * Replace `/github token` with `/github tokens` * Update the reusable action, workflow, documentation, and tests --- .github/actions/notify-wire/action.yml | 8 ++-- .github/workflows/pull-request.yml | 2 +- README.md | 29 ++++++++------ .../kotlin/com/wire/github/EventsHandler.kt | 2 +- .../com/wire/github/GitHubCommandHandler.kt | 30 +++++++++----- .../wire/github/util/ActionsTokenGenerator.kt | 15 +++++++ .../wire/github/util/ActionsTokenValidator.kt | 6 +-- .../kotlin/com/wire/github/util/Extensions.kt | 5 +++ .../wire/github/GitHubCommandHandlerTest.kt | 40 +++++++++++++++---- .../github/util/ActionsTokenGeneratorTest.kt | 18 +++++++++ .../github/util/ActionsTokenValidatorTest.kt | 20 +++++++--- 11 files changed, 130 insertions(+), 45 deletions(-) create mode 100644 src/main/kotlin/com/wire/github/util/ActionsTokenGenerator.kt create mode 100644 src/test/kotlin/com/wire/github/util/ActionsTokenGeneratorTest.kt diff --git a/.github/actions/notify-wire/action.yml b/.github/actions/notify-wire/action.yml index 9ad020f..2b544d7 100644 --- a/.github/actions/notify-wire/action.yml +++ b/.github/actions/notify-wire/action.yml @@ -4,8 +4,8 @@ inputs: webhook-url: description: Full github-app Actions endpoint URL for the Wire conversation required: true - secret: - description: Conversation secret configured for github-app + token: + description: GitHub Actions token configured for the Wire conversation required: true text: description: Message text to send @@ -17,13 +17,13 @@ runs: shell: bash env: WIRE_WEBHOOK_URL: ${{ inputs.webhook-url }} - CONVERSATION_SECRET: ${{ inputs.secret }} + WIRE_ACTIONS_TOKEN: ${{ inputs.token }} MESSAGE_TEXT: ${{ inputs.text }} run: | set -euo pipefail python3 -c 'import json, os; print(json.dumps({"text": os.environ["MESSAGE_TEXT"]}))' \ | curl --fail-with-body --silent --show-error \ -X POST "$WIRE_WEBHOOK_URL" \ - -H "Authorization: Bearer ${CONVERSATION_SECRET}" \ + -H "Authorization: Bearer ${WIRE_ACTIONS_TOKEN}" \ -H "Content-Type: application/json" \ --data-binary @- diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index a75cbdd..03fada3 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -43,7 +43,7 @@ jobs: uses: ./.github/actions/notify-wire with: webhook-url: ${{ secrets.WIRE_WEBHOOK_URL }} - secret: ${{ secrets.WIRE_WEBHOOK_SECRET }} + token: ${{ secrets.WIRE_ACTIONS_TOKEN }} text: | ✅ notify-wire staging test Repository: ${{ github.repository }} diff --git a/README.md b/README.md index 692d8e2..8a107ce 100644 --- a/README.md +++ b/README.md @@ -47,17 +47,20 @@ Use `/github webhook help` in the Wire conversation for its URL, secret, and set ### Workflow-authored GitHub Actions notifications -Use the reusable composite action to send workflow-composed text to a Wire conversation. The endpoint authenticates with the conversation secret already configured for GitHub webhooks. Store it as a GitHub Actions secret and do not print it in workflow logs. Empty or whitespace-only messages are rejected with `400`; a missing/invalid secret is rejected with `403`. +Use the reusable composite action to send workflow-composed text to a Wire conversation. The endpoint +authenticates with a dedicated per-conversation Actions token. Store it as a GitHub Actions secret and do +not print it in workflow logs. Empty or whitespace-only messages are rejected with `400`; a missing or +invalid token is rejected with `403`. -Use `/github actions help` in the Wire conversation to obtain the complete endpoint URL and shared -conversation secret. Store them as `WIRE_WEBHOOK_URL` and `WIRE_WEBHOOK_SECRET` respectively. +Use `/github actions help` in the Wire conversation to obtain the complete endpoint URL and Actions +token. Store them as `WIRE_WEBHOOK_URL` and `WIRE_ACTIONS_TOKEN` respectively. The action accepts these inputs: | Input | Required | Description | |-------|----------|-------------| | `webhook-url` | Yes | Complete `/actions/{conversation_id}/{conversation_domain}` URL. | -| `secret` | Yes | Shared conversation secret used as the bearer token. | +| `token` | Yes | Dedicated Actions bearer token. | | `text` | Yes | Complete message text to send without service-side formatting. | ```yaml @@ -66,7 +69,7 @@ The action accepts these inputs: uses: wireapp/github-app/.github/actions/notify-wire@v1 with: webhook-url: ${{ secrets.WIRE_WEBHOOK_URL }} - secret: ${{ secrets.WIRE_WEBHOOK_SECRET }} + token: ${{ secrets.WIRE_ACTIONS_TOKEN }} text: | CI failed for ${{ github.repository }} on `${{ github.ref_name }}`. Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} @@ -90,19 +93,19 @@ The endpoint returns: | Command | Description | |---------|-------------| | `/github help` | List available commands and notification approaches. | -| `/github token` | Show or create the shared conversation token. | +| `/github tokens` | Show or create the webhook secret and Actions token. | | `/github webhook help` | Show repository webhook setup instructions. | | `/github actions help` | Show the Actions endpoint, token usage, and reusable action example. | -### Conversation secret storage +### Conversation credential storage -The service stores one generated secret per qualified Wire conversation in Redis. The repository webhook -endpoint uses it for GitHub HMAC validation, while the Actions endpoint uses the same value as a bearer -token. `/github token`, `/github webhook help`, and `/github actions help` return the existing value or -create and store one when the conversation does not have one yet. +The service stores separate credentials per qualified Wire conversation in Redis. The repository webhook +endpoint uses its existing secret for GitHub HMAC validation. The Actions endpoint uses a dedicated, +randomly generated bearer token. `/github tokens` shows or creates both credentials; each focused help +command only shows or creates the credential it needs. -Treat this secret as a credential: anyone with the Actions endpoint and secret can post messages to the -conversation. Keep it in GitHub Actions secrets and avoid exposing it in workflow output. +Treat both values as credentials. Anyone with the Actions endpoint and Actions token can post messages to +the conversation. Keep credentials in GitHub secrets and avoid exposing them in workflow output. ### Reusable action releases diff --git a/src/main/kotlin/com/wire/github/EventsHandler.kt b/src/main/kotlin/com/wire/github/EventsHandler.kt index 2729697..ccc1cb1 100644 --- a/src/main/kotlin/com/wire/github/EventsHandler.kt +++ b/src/main/kotlin/com/wire/github/EventsHandler.kt @@ -26,7 +26,7 @@ class EventsHandler : WireEventsHandlerSuspending() { "conversationId: ${wireMessage.conversationId}, " + "senderId: ${wireMessage.sender}" ) - if (!wireMessage.text.equals(GitHubCommandHandler.TOKEN_COMMAND, ignoreCase = true)) { + if (!wireMessage.text.equals(GitHubCommandHandler.TOKENS_COMMAND, ignoreCase = true)) { usageMetrics.onHelpCommand() } diff --git a/src/main/kotlin/com/wire/github/GitHubCommandHandler.kt b/src/main/kotlin/com/wire/github/GitHubCommandHandler.kt index 41c7c04..9e46aa9 100644 --- a/src/main/kotlin/com/wire/github/GitHubCommandHandler.kt +++ b/src/main/kotlin/com/wire/github/GitHubCommandHandler.kt @@ -1,7 +1,9 @@ package com.wire.github import com.wire.github.util.ENV_VAR_HOST +import com.wire.github.util.ActionsTokenGenerator import com.wire.github.util.SessionIdentifierGenerator +import com.wire.github.util.toActionsTokenStorageKey import com.wire.github.util.toStorageKey import com.wire.sdk.model.QualifiedId import io.lettuce.core.api.sync.RedisCommands @@ -9,7 +11,8 @@ import io.lettuce.core.api.sync.RedisCommands internal class GitHubCommandHandler( private val storage: RedisCommands, private val host: String = ENV_VAR_HOST, - private val generateSecret: () -> String = SessionIdentifierGenerator::generate + private val generateSecret: () -> String = SessionIdentifierGenerator::generate, + private val generateActionsToken: () -> String = ActionsTokenGenerator::generate ) { fun response( command: String, @@ -17,7 +20,7 @@ internal class GitHubCommandHandler( ): String? = when { command.equals(HELP_COMMAND, ignoreCase = true) -> help() - command.equals(TOKEN_COMMAND, ignoreCase = true) -> token(conversationId) + command.equals(TOKENS_COMMAND, ignoreCase = true) -> tokens(conversationId) command.equals(WEBHOOK_HELP_COMMAND, ignoreCase = true) -> webhookHelp(conversationId) command.equals(ACTIONS_HELP_COMMAND, ignoreCase = true) -> actionsHelp(conversationId) else -> null @@ -26,13 +29,15 @@ internal class GitHubCommandHandler( fun help(): String = "GitHub App supports repository webhooks and workflow-authored Actions notifications.\n\n" + "Available commands:\n" + - "- `$TOKEN_COMMAND` — show or create this conversation's token\n" + + "- `$TOKENS_COMMAND` — show or create this conversation's credentials\n" + "- `$WEBHOOK_HELP_COMMAND` — configure GitHub repository webhooks\n" + "- `$ACTIONS_HELP_COMMAND` — send custom messages from GitHub Actions" - private fun token(conversationId: QualifiedId): String = - "Conversation token:\n\n`${getOrCreateSecret(conversationId)}`\n\n" + - "Store this value as a secret. Do not print it in workflow logs." + private fun tokens(conversationId: QualifiedId): String = + "Conversation credentials:\n\n" + + "GitHub webhook secret:\n`${getOrCreateSecret(conversationId)}`\n\n" + + "GitHub Actions token:\n`${getOrCreateActionsToken(conversationId)}`\n\n" + + "Store these values as secrets. Do not print them in workflow logs." fun webhookHelp(conversationId: QualifiedId): String { val webhookUrl = "$normalizedHost/${conversationId.id}/${conversationId.domain}" @@ -50,13 +55,14 @@ internal class GitHubCommandHandler( val actionsUrl = "$normalizedHost/actions/${conversationId.id}/${conversationId.domain}" return "GitHub Actions notification setup:\n\n" + "Store this URL as `WIRE_WEBHOOK_URL`:\n$actionsUrl\n\n" + - "Store this token as `WIRE_WEBHOOK_SECRET`:\n${getOrCreateSecret(conversationId)}\n\n" + + "Store this token as `WIRE_ACTIONS_TOKEN`:\n" + + "${getOrCreateActionsToken(conversationId)}\n\n" + "Use the reusable action:\n\n" + "```yaml\n" + "uses: wireapp/github-app/.github/actions/notify-wire@v1\n" + "with:\n" + " webhook-url: \${{ secrets.WIRE_WEBHOOK_URL }}\n" + - " secret: \${{ secrets.WIRE_WEBHOOK_SECRET }}\n" + + " token: \${{ secrets.WIRE_ACTIONS_TOKEN }}\n" + " text: Message to send to Wire\n" + "```" } @@ -66,12 +72,18 @@ internal class GitHubCommandHandler( return storage.get(storageKey) ?: generateSecret().also { storage.set(storageKey, it) } } + private fun getOrCreateActionsToken(conversationId: QualifiedId): String { + val storageKey = conversationId.toActionsTokenStorageKey() + return storage.get(storageKey) + ?: generateActionsToken().also { storage.set(storageKey, it) } + } + private val normalizedHost: String get() = host.trimEnd('/') companion object { const val HELP_COMMAND = "/github help" - const val TOKEN_COMMAND = "/github token" + const val TOKENS_COMMAND = "/github tokens" const val WEBHOOK_HELP_COMMAND = "/github webhook help" const val ACTIONS_HELP_COMMAND = "/github actions help" } diff --git a/src/main/kotlin/com/wire/github/util/ActionsTokenGenerator.kt b/src/main/kotlin/com/wire/github/util/ActionsTokenGenerator.kt new file mode 100644 index 0000000..708f37c --- /dev/null +++ b/src/main/kotlin/com/wire/github/util/ActionsTokenGenerator.kt @@ -0,0 +1,15 @@ +package com.wire.github.util + +import java.security.SecureRandom +import java.util.Base64 + +object ActionsTokenGenerator { + private const val TOKEN_SIZE_BYTES = 32 + private val random = SecureRandom() + + fun generate(): String { + val bytes = ByteArray(TOKEN_SIZE_BYTES) + random.nextBytes(bytes) + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + } +} diff --git a/src/main/kotlin/com/wire/github/util/ActionsTokenValidator.kt b/src/main/kotlin/com/wire/github/util/ActionsTokenValidator.kt index 40a2acc..dd7f69c 100644 --- a/src/main/kotlin/com/wire/github/util/ActionsTokenValidator.kt +++ b/src/main/kotlin/com/wire/github/util/ActionsTokenValidator.kt @@ -14,10 +14,10 @@ class ActionsTokenValidator( conversationDomain: String, token: String ): Boolean { - val storageKey = conversationId.toStorageKey(domain = conversationDomain) - val secret = storage.get(storageKey) ?: return false + val storageKey = conversationId.toActionsTokenStorageKey(domain = conversationDomain) + val storedToken = storage.get(storageKey) ?: return false return MessageDigest.isEqual( - secret.toByteArray(StandardCharsets.UTF_8), + storedToken.toByteArray(StandardCharsets.UTF_8), token.toByteArray(StandardCharsets.UTF_8) ) } diff --git a/src/main/kotlin/com/wire/github/util/Extensions.kt b/src/main/kotlin/com/wire/github/util/Extensions.kt index 684daaf..15d2e7f 100644 --- a/src/main/kotlin/com/wire/github/util/Extensions.kt +++ b/src/main/kotlin/com/wire/github/util/Extensions.kt @@ -3,7 +3,12 @@ package com.wire.github.util import com.wire.sdk.model.QualifiedId private const val STORAGE_KEY_PREFIX = "github-app:" +private const val ACTIONS_TOKEN_SUFFIX = ":actions-token" fun QualifiedId.toStorageKey() = "$STORAGE_KEY_PREFIX${this.id}@${this.domain}" fun String.toStorageKey(domain: String) = "$STORAGE_KEY_PREFIX$this@$domain" + +fun QualifiedId.toActionsTokenStorageKey() = "${toStorageKey()}$ACTIONS_TOKEN_SUFFIX" + +fun String.toActionsTokenStorageKey(domain: String) = "${toStorageKey(domain)}$ACTIONS_TOKEN_SUFFIX" diff --git a/src/test/kotlin/com/wire/github/GitHubCommandHandlerTest.kt b/src/test/kotlin/com/wire/github/GitHubCommandHandlerTest.kt index 614bcd5..e00a1d3 100644 --- a/src/test/kotlin/com/wire/github/GitHubCommandHandlerTest.kt +++ b/src/test/kotlin/com/wire/github/GitHubCommandHandlerTest.kt @@ -1,5 +1,6 @@ package com.wire.github +import com.wire.github.util.toActionsTokenStorageKey import com.wire.github.util.toStorageKey import com.wire.sdk.model.QualifiedId import io.lettuce.core.api.sync.RedisCommands @@ -16,37 +17,50 @@ class GitHubCommandHandlerTest { private val handler = GitHubCommandHandler( storage = storage, host = "https://github-app.example.com/", - generateSecret = { GENERATED_SECRET } + generateSecret = { GENERATED_SECRET }, + generateActionsToken = { GENERATED_ACTIONS_TOKEN } ) @Test fun `help lists all available commands`() { val response = handler.response(GitHubCommandHandler.HELP_COMMAND, CONVERSATION_ID) - assertContains(response.orEmpty(), GitHubCommandHandler.TOKEN_COMMAND) + assertContains(response.orEmpty(), GitHubCommandHandler.TOKENS_COMMAND) assertContains(response.orEmpty(), GitHubCommandHandler.WEBHOOK_HELP_COMMAND) assertContains(response.orEmpty(), GitHubCommandHandler.ACTIONS_HELP_COMMAND) } @Test - fun `token returns existing conversation token`() { + fun `tokens returns existing webhook secret and Actions token`() { every { storage.get(CONVERSATION_ID.toStorageKey()) } returns EXISTING_SECRET + every { + storage.get(CONVERSATION_ID.toActionsTokenStorageKey()) + } returns EXISTING_ACTIONS_TOKEN - val response = handler.response(GitHubCommandHandler.TOKEN_COMMAND, CONVERSATION_ID) + val response = handler.response(GitHubCommandHandler.TOKENS_COMMAND, CONVERSATION_ID) assertContains(response.orEmpty(), EXISTING_SECRET) + assertContains(response.orEmpty(), EXISTING_ACTIONS_TOKEN) verify(exactly = 0) { storage.set(any(), any()) } } @Test - fun `token creates and stores conversation token when one is missing`() { + fun `tokens creates and stores both credentials when they are missing`() { every { storage.get(CONVERSATION_ID.toStorageKey()) } returns null every { storage.set(CONVERSATION_ID.toStorageKey(), GENERATED_SECRET) } returns "OK" + every { storage.get(CONVERSATION_ID.toActionsTokenStorageKey()) } returns null + every { + storage.set(CONVERSATION_ID.toActionsTokenStorageKey(), GENERATED_ACTIONS_TOKEN) + } returns "OK" - val response = handler.response(GitHubCommandHandler.TOKEN_COMMAND, CONVERSATION_ID) + val response = handler.response(GitHubCommandHandler.TOKENS_COMMAND, CONVERSATION_ID) assertContains(response.orEmpty(), GENERATED_SECRET) + assertContains(response.orEmpty(), GENERATED_ACTIONS_TOKEN) verify(exactly = 1) { storage.set(CONVERSATION_ID.toStorageKey(), GENERATED_SECRET) } + verify(exactly = 1) { + storage.set(CONVERSATION_ID.toActionsTokenStorageKey(), GENERATED_ACTIONS_TOKEN) + } } @Test @@ -64,7 +78,9 @@ class GitHubCommandHandlerTest { @Test fun `Actions help includes endpoint token and reusable action`() { - every { storage.get(CONVERSATION_ID.toStorageKey()) } returns EXISTING_SECRET + every { + storage.get(CONVERSATION_ID.toActionsTokenStorageKey()) + } returns EXISTING_ACTIONS_TOKEN val response = handler.response(GitHubCommandHandler.ACTIONS_HELP_COMMAND, CONVERSATION_ID) @@ -72,9 +88,15 @@ class GitHubCommandHandlerTest { response.orEmpty(), "https://github-app.example.com/actions/${CONVERSATION_ID.id}/${CONVERSATION_ID.domain}" ) - assertContains(response.orEmpty(), EXISTING_SECRET) + assertContains(response.orEmpty(), EXISTING_ACTIONS_TOKEN) assertContains(response.orEmpty(), "wireapp/github-app/.github/actions/notify-wire@v1") assertContains(response.orEmpty(), "webhook-url") + assertContains(response.orEmpty(), "token") + } + + @Test + fun `old singular token command is ignored`() { + assertNull(handler.response("/github token", CONVERSATION_ID)) } @Test @@ -86,5 +108,7 @@ class GitHubCommandHandlerTest { val CONVERSATION_ID = QualifiedId(UUID.randomUUID(), "conversation.example.com") const val EXISTING_SECRET = "existing-secret" const val GENERATED_SECRET = "generated-secret" + const val EXISTING_ACTIONS_TOKEN = "existing-actions-token" + const val GENERATED_ACTIONS_TOKEN = "generated-actions-token" } } diff --git a/src/test/kotlin/com/wire/github/util/ActionsTokenGeneratorTest.kt b/src/test/kotlin/com/wire/github/util/ActionsTokenGeneratorTest.kt new file mode 100644 index 0000000..1ebcfc1 --- /dev/null +++ b/src/test/kotlin/com/wire/github/util/ActionsTokenGeneratorTest.kt @@ -0,0 +1,18 @@ +package com.wire.github.util + +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class ActionsTokenGeneratorTest { + @Test + fun `generates a random URL-safe token from 32 bytes`() { + val firstToken = ActionsTokenGenerator.generate() + val secondToken = ActionsTokenGenerator.generate() + + assertEquals(43, firstToken.length) + assertEquals(32, Base64.getUrlDecoder().decode(firstToken).size) + assertNotEquals(firstToken, secondToken) + } +} diff --git a/src/test/kotlin/com/wire/github/util/ActionsTokenValidatorTest.kt b/src/test/kotlin/com/wire/github/util/ActionsTokenValidatorTest.kt index ceccc61..15fcf1d 100644 --- a/src/test/kotlin/com/wire/github/util/ActionsTokenValidatorTest.kt +++ b/src/test/kotlin/com/wire/github/util/ActionsTokenValidatorTest.kt @@ -20,28 +20,36 @@ class ActionsTokenValidatorTest { @Test fun `matching stored token is valid`() { - every { storage.get(CONVERSATION_ID.toStorageKey(CONVERSATION_DOMAIN)) } returns SECRET + every { + storage.get(ACTIONS_STORAGE_KEY) + } returns TOKEN - assertTrue(validator.isValid(CONVERSATION_ID, CONVERSATION_DOMAIN, SECRET)) + assertTrue(validator.isValid(CONVERSATION_ID, CONVERSATION_DOMAIN, TOKEN)) } @Test fun `wrong token is invalid`() { - every { storage.get(CONVERSATION_ID.toStorageKey(CONVERSATION_DOMAIN)) } returns SECRET + every { + storage.get(ACTIONS_STORAGE_KEY) + } returns TOKEN assertFalse(validator.isValid(CONVERSATION_ID, CONVERSATION_DOMAIN, "wrong-secret")) } @Test fun `missing stored token is invalid`() { - every { storage.get(CONVERSATION_ID.toStorageKey(CONVERSATION_DOMAIN)) } returns null + every { + storage.get(ACTIONS_STORAGE_KEY) + } returns null - assertFalse(validator.isValid(CONVERSATION_ID, CONVERSATION_DOMAIN, SECRET)) + assertFalse(validator.isValid(CONVERSATION_ID, CONVERSATION_DOMAIN, TOKEN)) } private companion object { val CONVERSATION_ID = UUID.randomUUID().toString() const val CONVERSATION_DOMAIN = "conversation.example.com" - const val SECRET = "conversation-secret" + val ACTIONS_STORAGE_KEY = + "github-app:$CONVERSATION_ID@$CONVERSATION_DOMAIN:actions-token" + const val TOKEN = "actions-token" } } From a47f127a6faf7393a25a97a118786db24395a0cf Mon Sep 17 00:00:00 2001 From: alexandreferris Date: Wed, 30 Sep 2026 15:43:51 +0200 Subject: [PATCH 06/12] feat: Add Github Custom Actions #WPB-28781 * Upgrade to JVM SDK version 0.3.0 * Remove now unused APP ID --- README.md | 1 - build.gradle.kts | 1 - docker-compose.yml | 1 - gradle/libs.versions.toml | 2 +- helm/githubapp/values-prod.yaml | 2 -- helm/githubapp/values.yaml | 2 -- src/main/kotlin/com/wire/github/config/Modules.kt | 2 -- .../com/wire/github/util/EnvironmentVariables.kt | 14 -------------- 8 files changed, 1 insertion(+), 24 deletions(-) diff --git a/README.md b/README.md index 8a107ce..970c3e7 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,6 @@ GHAPP_SERVER_PORT=8083 GHAPP_REDIS_URL=redis://username:password@host:port WIRE_SDK_API_HOST=https://nginz-https.chala.wire.link WIRE_SDK_API_TOKEN=myApiToken -WIRE_SDK_APP_ID=f562e146-dec2-4d85-93c7-7132746b5cca WIRE_SDK_CRYPTOGRAPHY_STORAGE_PASSWORD=myDummyPasswordmyDummyPassword01 ``` diff --git a/build.gradle.kts b/build.gradle.kts index 5713803..3aaeb56 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -118,7 +118,6 @@ tasks { environment("GHAPP_REDIS_URL", "redis://localhost:6379") environment("WIRE_SDK_API_HOST", "https://nginz-https.chala.wire.link") environment("WIRE_SDK_API_TOKEN", "myApiToken") - environment("WIRE_SDK_APP_ID", "f562e146-dec2-4d85-93c7-7132746b5cca") environment("WIRE_SDK_CRYPTOGRAPHY_STORAGE_PASSWORD", "myDummyPasswordmyDummyPassword01") } } diff --git a/docker-compose.yml b/docker-compose.yml index 9b58b0f..83b10e2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,6 @@ services: - GHAPP_REDIS_URL=${GHAPP_REDIS_URL} - WIRE_SDK_API_HOST=${WIRE_SDK_API_HOST} - WIRE_SDK_API_TOKEN=${WIRE_SDK_API_TOKEN} - - WIRE_SDK_APP_ID=${WIRE_SDK_APP_ID} - WIRE_SDK_CRYPTOGRAPHY_STORAGE_PASSWORD=${WIRE_SDK_CRYPTOGRAPHY_STORAGE_PASSWORD} ports: - "${GHAPP_SERVER_PORT:-8083}:${GHAPP_SERVER_PORT:-8083}" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 270125a..9949dec 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,7 +8,7 @@ ktlint-version = "14.2.0" detekt-version = "1.23.8" kotlin-serialization = "1.11.0" koin-version = "4.2.2" -wire-sdk-version = "0.2.1" +wire-sdk-version = "0.3.0" redis-version = "6.7.1.RELEASE" mustache-version = "0.9.14" mockk-version = "1.14.11" diff --git a/helm/githubapp/values-prod.yaml b/helm/githubapp/values-prod.yaml index 7db0cc7..35329e8 100644 --- a/helm/githubapp/values-prod.yaml +++ b/helm/githubapp/values-prod.yaml @@ -17,8 +17,6 @@ ingress: - github-app.apps-prod.wire.com secretName: github-app-tls env: -- name: WIRE_SDK_APP_ID - value: "61c2f1c4-ca8a-4d67-8f05-ca9bb37d85cc" - name: WIRE_SDK_API_HOST value: "https://prod-nginz-https.wire.com" - name: GHAPP_API_HOST diff --git a/helm/githubapp/values.yaml b/helm/githubapp/values.yaml index 1d11802..3de530e 100644 --- a/helm/githubapp/values.yaml +++ b/helm/githubapp/values.yaml @@ -73,8 +73,6 @@ affinity: {} # Environment variables for the application env: - - name: WIRE_SDK_APP_ID - value: "" - name: WIRE_SDK_API_HOST value: "" - name: GHAPP_API_HOST diff --git a/src/main/kotlin/com/wire/github/config/Modules.kt b/src/main/kotlin/com/wire/github/config/Modules.kt index 475e79d..6270005 100644 --- a/src/main/kotlin/com/wire/github/config/Modules.kt +++ b/src/main/kotlin/com/wire/github/config/Modules.kt @@ -5,7 +5,6 @@ import com.wire.github.metrics.UsageMetrics import com.wire.github.util.ActionsTokenValidator import com.wire.github.util.ENV_VAR_API_HOST import com.wire.github.util.ENV_VAR_API_TOKEN -import com.wire.github.util.ENV_VAR_APPLICATION_ID import com.wire.github.util.ENV_VAR_CRYPTOGRAPHY_STORAGE_KEY import com.wire.github.util.ENV_VAR_REDIS_URL import com.wire.github.util.SignatureValidator @@ -35,7 +34,6 @@ val projectModules = module { private fun wireAppSdk(): WireAppSdk = WireAppSdk( - applicationId = ENV_VAR_APPLICATION_ID, apiToken = ENV_VAR_API_TOKEN, apiHost = ENV_VAR_API_HOST, cryptographyStorageKey = ENV_VAR_CRYPTOGRAPHY_STORAGE_KEY.toByteArray(), diff --git a/src/main/kotlin/com/wire/github/util/EnvironmentVariables.kt b/src/main/kotlin/com/wire/github/util/EnvironmentVariables.kt index 3ae132e..f664c28 100644 --- a/src/main/kotlin/com/wire/github/util/EnvironmentVariables.kt +++ b/src/main/kotlin/com/wire/github/util/EnvironmentVariables.kt @@ -8,8 +8,6 @@ */ package com.wire.github.util -import java.util.UUID - /** * Host Port to be used when setting up Ktor server. */ @@ -44,18 +42,6 @@ val ENV_VAR_REDIS_URL: String = System "redis://localhost:6379" ) -/** - * Application ID received when Onboarding the App. - */ -val ENV_VAR_APPLICATION_ID: UUID = UUID.fromString( - System - .getenv() - .getOrDefault( - "WIRE_SDK_APP_ID", - UUID.randomUUID().toString() - ) -) - /** * API Token received when Onboarding the App. */ From 86df3dee9ba222e9efec4039ef498a24deb5cd3a Mon Sep 17 00:00:00 2001 From: alexandreferris Date: Wed, 30 Sep 2026 15:49:49 +0200 Subject: [PATCH 07/12] feat: Add Github Custom Actions #WPB-28781 * Use SHA commit for actions/checkout instead of version tag --- .github/workflows/claude-code-review.yml | 2 +- .github/workflows/claude.yml | 2 +- .github/workflows/pull-request.yml | 4 ++-- .github/workflows/staging.yml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 8070977..4fb86cc 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -17,7 +17,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: fetch-depth: 1 diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 97c5252..04fa7d8 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -26,7 +26,7 @@ jobs: actions: read # Required for Claude to read CI results on PRs steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: fetch-depth: 1 diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 03fada3..82e2134 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -8,7 +8,7 @@ jobs: tests: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c with: distribution: 'zulu' @@ -37,7 +37,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Send test notification to Wire uses: ./.github/actions/notify-wire diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index 462c9b3..846da60 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -16,7 +16,7 @@ jobs: if: github.actor != 'github-actions[bot]' steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 From 3901b5376870b950aba6c7cd77ca1e9230a26cf2 Mon Sep 17 00:00:00 2001 From: alexandreferris Date: Wed, 30 Sep 2026 16:39:05 +0200 Subject: [PATCH 08/12] feat: Add Github Custom Actions #WPB-28781 * Use QualifiedId throughout Actions token validation * Inject Redis commands directly into the token validator * Replace custom HTTP parsing with Ktor utilities * Consolidate credential get-or-create logic --- .../com/wire/github/GitHubCommandHandler.kt | 18 +++---- src/main/kotlin/com/wire/github/Routing.kt | 52 ++++++++----------- .../kotlin/com/wire/github/config/Modules.kt | 6 ++- .../wire/github/util/ActionsTokenValidator.kt | 12 ++--- .../kotlin/com/wire/github/util/Extensions.kt | 2 - .../kotlin/com/wire/github/ApplicationTest.kt | 31 +++++------ .../github/util/ActionsTokenValidatorTest.kt | 23 ++++---- 7 files changed, 65 insertions(+), 79 deletions(-) diff --git a/src/main/kotlin/com/wire/github/GitHubCommandHandler.kt b/src/main/kotlin/com/wire/github/GitHubCommandHandler.kt index 9e46aa9..c6a1638 100644 --- a/src/main/kotlin/com/wire/github/GitHubCommandHandler.kt +++ b/src/main/kotlin/com/wire/github/GitHubCommandHandler.kt @@ -67,16 +67,16 @@ internal class GitHubCommandHandler( "```" } - private fun getOrCreateSecret(conversationId: QualifiedId): String { - val storageKey = conversationId.toStorageKey() - return storage.get(storageKey) ?: generateSecret().also { storage.set(storageKey, it) } - } + private fun getOrCreateSecret(conversationId: QualifiedId): String = + getOrCreate(conversationId.toStorageKey(), generateSecret) - private fun getOrCreateActionsToken(conversationId: QualifiedId): String { - val storageKey = conversationId.toActionsTokenStorageKey() - return storage.get(storageKey) - ?: generateActionsToken().also { storage.set(storageKey, it) } - } + private fun getOrCreateActionsToken(conversationId: QualifiedId): String = + getOrCreate(conversationId.toActionsTokenStorageKey(), generateActionsToken) + + private fun getOrCreate( + storageKey: String, + generate: () -> String + ): String = storage.get(storageKey) ?: generate().also { storage.set(storageKey, it) } private val normalizedHost: String get() = host.trimEnd('/') diff --git a/src/main/kotlin/com/wire/github/Routing.kt b/src/main/kotlin/com/wire/github/Routing.kt index 4be34bd..0d5e678 100644 --- a/src/main/kotlin/com/wire/github/Routing.kt +++ b/src/main/kotlin/com/wire/github/Routing.kt @@ -11,14 +11,17 @@ import com.wire.sdk.WireAppSdk import com.wire.sdk.model.QualifiedId import com.wire.sdk.model.WireMessage import io.ktor.http.ContentType -import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode +import io.ktor.http.auth.HttpAuthHeader +import io.ktor.http.auth.parseAuthorizationHeader import io.ktor.serialization.kotlinx.json.json import io.ktor.server.application.Application import io.ktor.server.application.install import io.ktor.server.application.log import io.ktor.server.application.ApplicationCall import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.request.authorization +import io.ktor.server.request.contentType import io.ktor.server.request.receiveText import io.ktor.server.response.respond import io.ktor.server.response.respondText @@ -181,21 +184,22 @@ private fun Route.configureActionsRoute( HttpStatusCode.BadRequest, "Invalid conversation ID" ) + val qualifiedConversationId = QualifiedId(conversationUuid, conversationDomain) - if (!call.hasJsonContentType()) { + if (!call.request.contentType().match(ContentType.Application.Json)) { return@post call.respondActionsError( HttpStatusCode.UnsupportedMediaType, "Content-Type must be application/json" ) } - val bearerToken = call.request.headers[HttpHeaders.Authorization].toBearerToken() + val bearerToken = call.bearerToken() ?: return@post call.respondActionsError( HttpStatusCode.Unauthorized, "Bearer authorization is required" ) - if (!actionsTokenValidator.isValid(conversationId, conversationDomain, bearerToken)) { + if (!actionsTokenValidator.isValid(qualifiedConversationId, bearerToken)) { application.log.warn( "Invalid Actions token for conversation $conversationId@$conversationDomain" ) @@ -205,15 +209,12 @@ private fun Route.configureActionsRoute( ) } - val request = call.receiveActionsRequest(conversationId, conversationDomain) + val request = call.receiveActionsRequest(qualifiedConversationId) ?: return@post wireAppSdk.getApplicationManager().sendMessage( message = WireMessage.Text.create( - conversationId = QualifiedId( - id = conversationUuid, - domain = conversationDomain - ), + conversationId = qualifiedConversationId, text = request.text ) ) @@ -222,14 +223,14 @@ private fun Route.configureActionsRoute( } private suspend fun ApplicationCall.receiveActionsRequest( - conversationId: String, - conversationDomain: String + conversationId: QualifiedId ): ActionsNotificationRequest? { val request = try { KtxSerializer.json.decodeFromString(receiveText()) } catch (exception: SerializationException) { application.log.debug( - "Invalid Actions payload for conversation $conversationId@$conversationDomain", + "Invalid Actions payload for conversation {}", + conversationId, exception ) respondActionsError(HttpStatusCode.BadRequest, "Invalid JSON payload") @@ -251,24 +252,15 @@ private suspend fun ApplicationCall.respondActionsError( respondText(text = message, status = status) } -private fun ApplicationCall.hasJsonContentType(): Boolean = - request.headers[HttpHeaders.ContentType] - ?.substringBefore(';') - ?.trim() - ?.equals(ContentType.Application.Json.toString(), ignoreCase = true) == true +private fun ApplicationCall.bearerToken(): String? { + val authorization = request.authorization()?.trim() + val header = authorization + ?.let { runCatching { parseAuthorizationHeader(it) }.getOrNull() } + as? HttpAuthHeader.Single -private fun String?.toBearerToken(): String? { - val value = this?.trim() - val separator = value?.indexOf(' ') ?: -1 - return if (value != null && separator > 0) { - val scheme = value.substring(startIndex = 0, endIndex = separator) - val token = value.substring(startIndex = separator + 1).trim() - token.takeIf { - scheme.equals("Bearer", ignoreCase = true) && - it.isNotEmpty() && - it.none(Char::isWhitespace) - } - } else { - null + return header?.blob?.takeIf { + header.authScheme.equals("Bearer", ignoreCase = true) && + it.isNotEmpty() && + it.none(Char::isWhitespace) } } diff --git a/src/main/kotlin/com/wire/github/config/Modules.kt b/src/main/kotlin/com/wire/github/config/Modules.kt index 6270005..7334835 100644 --- a/src/main/kotlin/com/wire/github/config/Modules.kt +++ b/src/main/kotlin/com/wire/github/config/Modules.kt @@ -24,7 +24,11 @@ val projectModules = module { } } single { SignatureValidator() } - single { ActionsTokenValidator(redisConnection = get()) } + single { + ActionsTokenValidator( + storage = get>().sync() + ) + } single { TemplateHandler() } single { RedisClient.create(ENV_VAR_REDIS_URL) } single> { get().connect() } diff --git a/src/main/kotlin/com/wire/github/util/ActionsTokenValidator.kt b/src/main/kotlin/com/wire/github/util/ActionsTokenValidator.kt index dd7f69c..70754b3 100644 --- a/src/main/kotlin/com/wire/github/util/ActionsTokenValidator.kt +++ b/src/main/kotlin/com/wire/github/util/ActionsTokenValidator.kt @@ -1,20 +1,18 @@ package com.wire.github.util -import io.lettuce.core.api.StatefulRedisConnection +import com.wire.sdk.model.QualifiedId +import io.lettuce.core.api.sync.RedisCommands import java.nio.charset.StandardCharsets import java.security.MessageDigest class ActionsTokenValidator( - redisConnection: StatefulRedisConnection + private val storage: RedisCommands ) { - private val storage = redisConnection.sync() - fun isValid( - conversationId: String, - conversationDomain: String, + conversationId: QualifiedId, token: String ): Boolean { - val storageKey = conversationId.toActionsTokenStorageKey(domain = conversationDomain) + val storageKey = conversationId.toActionsTokenStorageKey() val storedToken = storage.get(storageKey) ?: return false return MessageDigest.isEqual( storedToken.toByteArray(StandardCharsets.UTF_8), diff --git a/src/main/kotlin/com/wire/github/util/Extensions.kt b/src/main/kotlin/com/wire/github/util/Extensions.kt index 15d2e7f..52cd19a 100644 --- a/src/main/kotlin/com/wire/github/util/Extensions.kt +++ b/src/main/kotlin/com/wire/github/util/Extensions.kt @@ -10,5 +10,3 @@ fun QualifiedId.toStorageKey() = "$STORAGE_KEY_PREFIX${this.id}@${this.domain}" fun String.toStorageKey(domain: String) = "$STORAGE_KEY_PREFIX$this@$domain" fun QualifiedId.toActionsTokenStorageKey() = "${toStorageKey()}$ACTIONS_TOKEN_SUFFIX" - -fun String.toActionsTokenStorageKey(domain: String) = "${toStorageKey(domain)}$ACTIONS_TOKEN_SUFFIX" diff --git a/src/test/kotlin/com/wire/github/ApplicationTest.kt b/src/test/kotlin/com/wire/github/ApplicationTest.kt index ce4e5cd..33bf0f1 100644 --- a/src/test/kotlin/com/wire/github/ApplicationTest.kt +++ b/src/test/kotlin/com/wire/github/ApplicationTest.kt @@ -52,7 +52,7 @@ class ApplicationTest { modules( module { single { SignatureValidator() } - single { ActionsTokenValidator(redisConnection = mockRedisConnection) } + single { ActionsTokenValidator(storage = mockRedisCommands) } single { TemplateHandler() } single { mockRedisClient } single> { mockRedisConnection } @@ -393,7 +393,7 @@ class ApplicationTest { } @Test - fun `given valid bearer secret, when Actions message is posted, then message is sent`() { + fun `given valid bearer token, when Actions message is posted, then message is sent`() { val (actionsTokenValidator, wireAppSdk) = loadActionsDependencies(tokenValid = true) val sentMessage = slot() every { @@ -403,8 +403,8 @@ class ApplicationTest { testApplication { application { configureRouting() } val response = client.post("/actions/${CONVERSATION_ID.id}/${CONVERSATION_ID.domain}") { - contentType(ContentType.Application.Json) - header("Authorization", "Bearer conversation-secret") + contentType(ContentType.parse("application/json; charset=UTF-8")) + header("Authorization", "bearer $ACTIONS_TOKEN") setBody("""{"text":"CI failed"}""") } @@ -413,11 +413,7 @@ class ApplicationTest { assertEquals(CONVERSATION_ID, sentMessage.captured.conversationId) assertEquals("CI failed", sentMessage.captured.text) verify(exactly = 1) { - actionsTokenValidator.isValid( - CONVERSATION_ID.id.toString(), - CONVERSATION_ID.domain, - "conversation-secret" - ) + actionsTokenValidator.isValid(CONVERSATION_ID, ACTIONS_TOKEN) } verify(exactly = 1) { wireAppSdk.getApplicationManager().sendMessage(message = any()) } } @@ -427,7 +423,7 @@ class ApplicationTest { fun `Actions message without bearer authorization is unauthorized`() { listOf( null, - "Basic conversation-secret", + "Basic $ACTIONS_TOKEN", "Bearer ", "Bearer token with-spaces" ).forEach { authorization -> @@ -445,7 +441,7 @@ class ApplicationTest { assertEquals(HttpStatusCode.Unauthorized, response.status) assertEquals("Bearer authorization is required", response.bodyAsText()) - verify(exactly = 0) { actionsTokenValidator.isValid(any(), any(), any()) } + verify(exactly = 0) { actionsTokenValidator.isValid(any(), any()) } verify(exactly = 0) { wireAppSdk.getApplicationManager().sendMessage(message = any()) } @@ -511,13 +507,13 @@ class ApplicationTest { "/actions/${CONVERSATION_ID.id}/${CONVERSATION_ID.domain}" ) { contentType(ContentType.Text.Plain) - header("Authorization", "Bearer conversation-secret") + header("Authorization", "Bearer $ACTIONS_TOKEN") setBody("""{"text":"CI failed"}""") } assertEquals(HttpStatusCode.UnsupportedMediaType, response.status) assertEquals("Content-Type must be application/json", response.bodyAsText()) - verify(exactly = 0) { actionsTokenValidator.isValid(any(), any(), any()) } + verify(exactly = 0) { actionsTokenValidator.isValid(any(), any()) } verify(exactly = 0) { wireAppSdk.getApplicationManager().sendMessage(message = any()) } @@ -532,13 +528,13 @@ class ApplicationTest { application { configureRouting() } val response = client.post("/actions/not-a-uuid/${CONVERSATION_ID.domain}") { contentType(ContentType.Application.Json) - header("Authorization", "Bearer conversation-secret") + header("Authorization", "Bearer $ACTIONS_TOKEN") setBody("""{"text":"CI failed"}""") } assertEquals(HttpStatusCode.BadRequest, response.status) assertEquals("Invalid conversation ID", response.bodyAsText()) - verify(exactly = 0) { actionsTokenValidator.isValid(any(), any(), any()) } + verify(exactly = 0) { actionsTokenValidator.isValid(any(), any()) } verify(exactly = 0) { wireAppSdk.getApplicationManager().sendMessage(message = any()) } } } @@ -547,7 +543,7 @@ class ApplicationTest { tokenValid: Boolean ): Pair { val actionsTokenValidator = mockk() - every { actionsTokenValidator.isValid(any(), any(), any()) } returns tokenValid + every { actionsTokenValidator.isValid(any(), any()) } returns tokenValid val wireAppSdk = mockk(relaxed = true) loadKoinModules( module { @@ -562,7 +558,7 @@ class ApplicationTest { payload: String = """{"text":"CI failed"}""" ) = post("/actions/${CONVERSATION_ID.id}/${CONVERSATION_ID.domain}") { contentType(ContentType.Application.Json) - header("Authorization", "Bearer conversation-secret") + header("Authorization", "Bearer $ACTIONS_TOKEN") setBody(payload) } @@ -574,6 +570,7 @@ class ApplicationTest { const val DUMMY_EVENT = "pull_request" const val DUMMY_SIGNATURE = "dummySignature" const val DUMMY_TEMPLATE = "dummyTemplate" + const val ACTIONS_TOKEN = "actions-token" val DUMMY_PAYLOAD = """ { "action": "created", diff --git a/src/test/kotlin/com/wire/github/util/ActionsTokenValidatorTest.kt b/src/test/kotlin/com/wire/github/util/ActionsTokenValidatorTest.kt index 15fcf1d..6f546db 100644 --- a/src/test/kotlin/com/wire/github/util/ActionsTokenValidatorTest.kt +++ b/src/test/kotlin/com/wire/github/util/ActionsTokenValidatorTest.kt @@ -1,6 +1,6 @@ package com.wire.github.util -import io.lettuce.core.api.StatefulRedisConnection +import com.wire.sdk.model.QualifiedId import io.lettuce.core.api.sync.RedisCommands import io.mockk.every import io.mockk.mockk @@ -11,12 +11,7 @@ import kotlin.test.assertTrue class ActionsTokenValidatorTest { private val storage = mockk>() - private val redisConnection = mockk>() - private val validator by lazy { ActionsTokenValidator(redisConnection) } - - init { - every { redisConnection.sync() } returns storage - } + private val validator = ActionsTokenValidator(storage) @Test fun `matching stored token is valid`() { @@ -24,7 +19,7 @@ class ActionsTokenValidatorTest { storage.get(ACTIONS_STORAGE_KEY) } returns TOKEN - assertTrue(validator.isValid(CONVERSATION_ID, CONVERSATION_DOMAIN, TOKEN)) + assertTrue(validator.isValid(CONVERSATION_ID, TOKEN)) } @Test @@ -33,7 +28,7 @@ class ActionsTokenValidatorTest { storage.get(ACTIONS_STORAGE_KEY) } returns TOKEN - assertFalse(validator.isValid(CONVERSATION_ID, CONVERSATION_DOMAIN, "wrong-secret")) + assertFalse(validator.isValid(CONVERSATION_ID, "wrong-token")) } @Test @@ -42,14 +37,16 @@ class ActionsTokenValidatorTest { storage.get(ACTIONS_STORAGE_KEY) } returns null - assertFalse(validator.isValid(CONVERSATION_ID, CONVERSATION_DOMAIN, TOKEN)) + assertFalse(validator.isValid(CONVERSATION_ID, TOKEN)) } private companion object { - val CONVERSATION_ID = UUID.randomUUID().toString() - const val CONVERSATION_DOMAIN = "conversation.example.com" + val CONVERSATION_ID = QualifiedId( + UUID.randomUUID(), + "conversation.example.com" + ) val ACTIONS_STORAGE_KEY = - "github-app:$CONVERSATION_ID@$CONVERSATION_DOMAIN:actions-token" + "github-app:${CONVERSATION_ID.id}@${CONVERSATION_ID.domain}:actions-token" const val TOKEN = "actions-token" } } From 99248de8adfc0ebb7e5a592c1555fd2e408a0c7f Mon Sep 17 00:00:00 2001 From: alexandreferris Date: Wed, 30 Sep 2026 17:38:54 +0200 Subject: [PATCH 09/12] feat: Add Github Custom Actions #WPB-28781 * move help command metrics into GitHubCommandHandler * simplify command dispatch and update tests --- .../kotlin/com/wire/github/EventsHandler.kt | 9 ++++---- .../com/wire/github/GitHubCommandHandler.kt | 15 ++++++++----- .../wire/github/GitHubCommandHandlerTest.kt | 21 ++++++++++++++++++- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/src/main/kotlin/com/wire/github/EventsHandler.kt b/src/main/kotlin/com/wire/github/EventsHandler.kt index ccc1cb1..d150cca 100644 --- a/src/main/kotlin/com/wire/github/EventsHandler.kt +++ b/src/main/kotlin/com/wire/github/EventsHandler.kt @@ -12,8 +12,11 @@ import org.slf4j.LoggerFactory class EventsHandler : WireEventsHandlerSuspending() { private val logger = LoggerFactory.getLogger(this::class.java) private val redisConnection = GlobalContext.get().get>() - private val commandHandler = GitHubCommandHandler(storage = redisConnection.sync()) private val usageMetrics = GlobalContext.get().get() + private val commandHandler = GitHubCommandHandler( + storage = redisConnection.sync(), + usageMetrics = usageMetrics + ) override suspend fun onTextMessageReceived(wireMessage: WireMessage.Text) { val response = commandHandler.response( @@ -26,10 +29,6 @@ class EventsHandler : WireEventsHandlerSuspending() { "conversationId: ${wireMessage.conversationId}, " + "senderId: ${wireMessage.sender}" ) - if (!wireMessage.text.equals(GitHubCommandHandler.TOKENS_COMMAND, ignoreCase = true)) { - usageMetrics.onHelpCommand() - } - manager.sendMessage( message = WireMessage.Text.create( conversationId = wireMessage.conversationId, diff --git a/src/main/kotlin/com/wire/github/GitHubCommandHandler.kt b/src/main/kotlin/com/wire/github/GitHubCommandHandler.kt index c6a1638..8e784af 100644 --- a/src/main/kotlin/com/wire/github/GitHubCommandHandler.kt +++ b/src/main/kotlin/com/wire/github/GitHubCommandHandler.kt @@ -1,5 +1,6 @@ package com.wire.github +import com.wire.github.metrics.UsageMetrics import com.wire.github.util.ENV_VAR_HOST import com.wire.github.util.ActionsTokenGenerator import com.wire.github.util.SessionIdentifierGenerator @@ -10,6 +11,7 @@ import io.lettuce.core.api.sync.RedisCommands internal class GitHubCommandHandler( private val storage: RedisCommands, + private val usageMetrics: UsageMetrics, private val host: String = ENV_VAR_HOST, private val generateSecret: () -> String = SessionIdentifierGenerator::generate, private val generateActionsToken: () -> String = ActionsTokenGenerator::generate @@ -18,11 +20,11 @@ internal class GitHubCommandHandler( command: String, conversationId: QualifiedId ): String? = - when { - command.equals(HELP_COMMAND, ignoreCase = true) -> help() - command.equals(TOKENS_COMMAND, ignoreCase = true) -> tokens(conversationId) - command.equals(WEBHOOK_HELP_COMMAND, ignoreCase = true) -> webhookHelp(conversationId) - command.equals(ACTIONS_HELP_COMMAND, ignoreCase = true) -> actionsHelp(conversationId) + when (command.lowercase()) { + HELP_COMMAND -> trackedHelp(help()) + TOKENS_COMMAND -> tokens(conversationId) + WEBHOOK_HELP_COMMAND -> trackedHelp(webhookHelp(conversationId)) + ACTIONS_HELP_COMMAND -> trackedHelp(actionsHelp(conversationId)) else -> null } @@ -66,6 +68,9 @@ internal class GitHubCommandHandler( " text: Message to send to Wire\n" + "```" } + + private fun trackedHelp(response: String): String = + response.also { usageMetrics.onHelpCommand() } private fun getOrCreateSecret(conversationId: QualifiedId): String = getOrCreate(conversationId.toStorageKey(), generateSecret) diff --git a/src/test/kotlin/com/wire/github/GitHubCommandHandlerTest.kt b/src/test/kotlin/com/wire/github/GitHubCommandHandlerTest.kt index e00a1d3..62b2f5d 100644 --- a/src/test/kotlin/com/wire/github/GitHubCommandHandlerTest.kt +++ b/src/test/kotlin/com/wire/github/GitHubCommandHandlerTest.kt @@ -1,5 +1,6 @@ package com.wire.github +import com.wire.github.metrics.UsageMetrics import com.wire.github.util.toActionsTokenStorageKey import com.wire.github.util.toStorageKey import com.wire.sdk.model.QualifiedId @@ -14,8 +15,10 @@ import kotlin.test.assertNull class GitHubCommandHandlerTest { private val storage = mockk>() + private val usageMetrics = mockk(relaxed = true) private val handler = GitHubCommandHandler( storage = storage, + usageMetrics = usageMetrics, host = "https://github-app.example.com/", generateSecret = { GENERATED_SECRET }, generateActionsToken = { GENERATED_ACTIONS_TOKEN } @@ -23,11 +26,12 @@ class GitHubCommandHandlerTest { @Test fun `help lists all available commands`() { - val response = handler.response(GitHubCommandHandler.HELP_COMMAND, CONVERSATION_ID) + val response = handler.response("/GITHUB HELP", CONVERSATION_ID) assertContains(response.orEmpty(), GitHubCommandHandler.TOKENS_COMMAND) assertContains(response.orEmpty(), GitHubCommandHandler.WEBHOOK_HELP_COMMAND) assertContains(response.orEmpty(), GitHubCommandHandler.ACTIONS_HELP_COMMAND) + verify(exactly = 1) { usageMetrics.onHelpCommand() } } @Test @@ -42,6 +46,7 @@ class GitHubCommandHandlerTest { assertContains(response.orEmpty(), EXISTING_SECRET) assertContains(response.orEmpty(), EXISTING_ACTIONS_TOKEN) verify(exactly = 0) { storage.set(any(), any()) } + verify(exactly = 0) { usageMetrics.onHelpCommand() } } @Test @@ -61,6 +66,7 @@ class GitHubCommandHandlerTest { verify(exactly = 1) { storage.set(CONVERSATION_ID.toActionsTokenStorageKey(), GENERATED_ACTIONS_TOKEN) } + verify(exactly = 0) { usageMetrics.onHelpCommand() } } @Test @@ -74,6 +80,7 @@ class GitHubCommandHandlerTest { "https://github-app.example.com/${CONVERSATION_ID.id}/${CONVERSATION_ID.domain}" ) assertContains(response.orEmpty(), EXISTING_SECRET) + verify(exactly = 1) { usageMetrics.onHelpCommand() } } @Test @@ -92,16 +99,28 @@ class GitHubCommandHandlerTest { assertContains(response.orEmpty(), "wireapp/github-app/.github/actions/notify-wire@v1") assertContains(response.orEmpty(), "webhook-url") assertContains(response.orEmpty(), "token") + verify(exactly = 1) { usageMetrics.onHelpCommand() } + } + + @Test + fun `webhook setup generated for app onboarding is not counted as a help command`() { + every { storage.get(CONVERSATION_ID.toStorageKey()) } returns EXISTING_SECRET + + handler.webhookHelp(CONVERSATION_ID) + + verify(exactly = 0) { usageMetrics.onHelpCommand() } } @Test fun `old singular token command is ignored`() { assertNull(handler.response("/github token", CONVERSATION_ID)) + verify(exactly = 0) { usageMetrics.onHelpCommand() } } @Test fun `unknown command is ignored`() { assertNull(handler.response("/github unknown", CONVERSATION_ID)) + verify(exactly = 0) { usageMetrics.onHelpCommand() } } private companion object { From 18d5b317e64d4eb98e119648db6346f2a9ce0078 Mon Sep 17 00:00:00 2001 From: alexandreferris Date: Wed, 30 Sep 2026 17:49:40 +0200 Subject: [PATCH 10/12] feat: Add Github Custom Actions #WPB-28781 * Update README --- README.md | 35 +---------------------------------- 1 file changed, 1 insertion(+), 34 deletions(-) diff --git a/README.md b/README.md index 970c3e7..921f37f 100644 --- a/README.md +++ b/README.md @@ -47,11 +47,6 @@ Use `/github webhook help` in the Wire conversation for its URL, secret, and set ### Workflow-authored GitHub Actions notifications -Use the reusable composite action to send workflow-composed text to a Wire conversation. The endpoint -authenticates with a dedicated per-conversation Actions token. Store it as a GitHub Actions secret and do -not print it in workflow logs. Empty or whitespace-only messages are rejected with `400`; a missing or -invalid token is rejected with `403`. - Use `/github actions help` in the Wire conversation to obtain the complete endpoint URL and Actions token. Store them as `WIRE_WEBHOOK_URL` and `WIRE_ACTIONS_TOKEN` respectively. @@ -75,20 +70,7 @@ The action accepts these inputs: Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} ``` -The action fails the workflow if the notification request fails; callers can opt into `continue-on-error: true` when notification delivery should not affect the job result. Pin the action to a released tag or commit SHA. - -The workflow owns the notification condition and complete message text. For example, use `if: always()` -for an unconditional notification or `if: failure() || cancelled()` for failure-only notification. - -The endpoint returns: - -- `200 OK` after Wire accepts the message; -- `400 Bad Request` for an invalid conversation ID, malformed JSON, or blank text; -- `401 Unauthorized` for missing or malformed bearer authorization; -- `403 Forbidden` for an invalid token or a conversation without a stored token; -- `415 Unsupported Media Type` when the request is not JSON. - -### Wire commands +## Wire commands | Command | Description | |---------|-------------| @@ -97,21 +79,6 @@ The endpoint returns: | `/github webhook help` | Show repository webhook setup instructions. | | `/github actions help` | Show the Actions endpoint, token usage, and reusable action example. | -### Conversation credential storage - -The service stores separate credentials per qualified Wire conversation in Redis. The repository webhook -endpoint uses its existing secret for GitHub HMAC validation. The Actions endpoint uses a dedicated, -randomly generated bearer token. `/github tokens` shows or creates both credentials; each focused help -command only shows or creates the credential it needs. - -Treat both values as credentials. Anyone with the Actions endpoint and Actions token can post messages to -the conversation. Keep credentials in GitHub secrets and avoid exposing them in workflow output. - -### Reusable action releases - -Publish immutable semantic-version tags such as `v1.0.0` for the reusable action. A moving `v1` tag may -also point to the latest compatible v1 release. Consumers that require reproducible workflows should pin -the action to an immutable version tag or commit SHA. ## Building & Running From d3f6c9f2ce04163014a7442e0c3c3fcab1471a15 Mon Sep 17 00:00:00 2001 From: alexandreferris Date: Wed, 30 Sep 2026 17:55:20 +0200 Subject: [PATCH 11/12] feat: Add Github Custom Actions #WPB-28781 * fix ktlint --- src/main/kotlin/com/wire/github/GitHubCommandHandler.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/wire/github/GitHubCommandHandler.kt b/src/main/kotlin/com/wire/github/GitHubCommandHandler.kt index 8e784af..6aea5ef 100644 --- a/src/main/kotlin/com/wire/github/GitHubCommandHandler.kt +++ b/src/main/kotlin/com/wire/github/GitHubCommandHandler.kt @@ -68,7 +68,7 @@ internal class GitHubCommandHandler( " text: Message to send to Wire\n" + "```" } - + private fun trackedHelp(response: String): String = response.also { usageMetrics.onHelpCommand() } From 920c8466dec6539c7a47531db61c36e2e370750f Mon Sep 17 00:00:00 2001 From: alexandreferris Date: Wed, 30 Sep 2026 17:59:23 +0200 Subject: [PATCH 12/12] feat: Add Github Custom Actions #WPB-28781 * remove test workflow dispatch with own action --- .github/workflows/pull-request.yml | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 82e2134..9739377 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -30,24 +30,3 @@ jobs: path: | **/build/reports/ **/build/test-results/ - - notify-wire-staging: - if: github.event_name == 'workflow_dispatch' - needs: tests - runs-on: ubuntu-22.04 - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - - - name: Send test notification to Wire - uses: ./.github/actions/notify-wire - with: - webhook-url: ${{ secrets.WIRE_WEBHOOK_URL }} - token: ${{ secrets.WIRE_ACTIONS_TOKEN }} - text: | - ✅ notify-wire staging test - Repository: ${{ github.repository }} - Ref: ${{ github.ref_name }} - Commit: ${{ github.sha }} - Actor: ${{ github.actor }} - Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}