Перейти к содержимому

GitLab CI Discord Webhook и Jenkins: уведомления о пайплайнах

GitLab CI Discord webhook и Jenkins: job в .gitlab-ci.yml с masked-переменными и on_failure, блок post{} в Jenkinsfile, цвет embed по статусу, меньше шума.

6 мин чтения Доступно также на English
На этой странице

Зелёный пайплайн может молчать, красный обязан достучаться до человека за минуту. И GitLab CI, и Jenkins умеют доставить этот сигнал в канал Discord одним curl и URL вебхука. В статье разберём .gitlab-ci.yml (masked-переменные, джобы on_success/on_failure, ссылка на пайплайн внутри embed), блок post {} в Jenkins (curl, httpRequest или плагин Discord Notifier), цвет по статусу и способ не получать сорок сообщений от пайплайна из сорока джобов.

Прячем URL вебхука в секрет

Создайте вебхук (настройки канала → Интеграции → Вебхуки) и скопируйте URL вида https://discord.com/api/webhooks/{id}/{token}. Токен — это и есть весь секрет: любой, у кого есть URL, может писать в канал, поэтому никогда не коммитьте его в .gitlab-ci.yml или Jenkinsfile.

GitLab: Settings → CI/CD → Variables → Add variable. Ключ DISCORD_WEBHOOK_URL, значение — URL, галочка Mask variable, чтобы в логах джобов вместо значения печаталось [MASKED]. Protect variable ставьте только если джобы с уведомлениями бегут исключительно на защищённых ветках и тегах — иначе пайплайны feature-веток получат пустую переменную.

У GitLab есть ограничения на маскируемое значение (одна строка, минимальная длина, ограниченный набор символов — см. документацию GitLab). Если форма отказывается маскировать URL, храните его в base64 и декодируйте в джобе:

# на своей машине
echo -n 'https://discord.com/api/webhooks/ID/TOKEN' | base64 -w0
# в джобе
script:
  - export DISCORD_WEBHOOK_URL=$(echo "$DISCORD_WEBHOOK_B64" | base64 -d)

Jenkins: Manage Jenkins → Credentials → Add Credentials → тип Secret text, ID discord-webhook-url. В пайплайне его читает withCredentials, он же маскирует значение в консольном логе.

GitLab CI: стадия notify с on_success и on_failure

Самая чистая схема — финальная стадия notify с двумя джобами: один выполняется, когда все предыдущие стадии прошли, второй — когда что-то упало. Общий скрипт лежит в скрытом шаблоне, а JSON собирает jq, чтобы кавычка в заголовке коммита не сломала payload.

stages:
  - build
  - test
  - notify

build:
  stage: build
  script:
    - npm ci && npm run build

test:
  stage: test
  script:
    - npm test

.discord_notify:
  stage: notify
  image: alpine:3.20
  before_script:
    - apk add --no-cache curl jq
  script:
    - |
      PAYLOAD=$(jq -n \
        --arg title "$STATUS_TEXT $CI_PROJECT_NAME" \
        --arg url "$CI_PIPELINE_URL" \
        --arg desc "$CI_COMMIT_TITLE" \
        --arg branch "$CI_COMMIT_REF_NAME" \
        --arg sha "$CI_COMMIT_SHORT_SHA" \
        --arg author "$GITLAB_USER_NAME" \
        --arg pipeline "$CI_PIPELINE_ID" \
        --argjson color "$STATUS_COLOR" \
        '{
          username: "GitLab CI",
          embeds: [{
            title: $title,
            url: $url,
            description: $desc,
            color: $color,
            fields: [
              { name: "Ветка", value: $branch, inline: true },
              { name: "Коммит", value: $sha, inline: true },
              { name: "Запустил", value: $author, inline: true }
            ],
            footer: { text: ("Пайплайн #" + $pipeline) },
            timestamp: (now | todate)
          }]
        }')
      curl -sS -f --retry 3 \
        -H "Content-Type: application/json" \
        -d "$PAYLOAD" "$DISCORD_WEBHOOK_URL"

notify_success:
  extends: .discord_notify
  when: on_success
  variables:
    STATUS_TEXT: '✅ Пайплайн прошёл:'
    STATUS_COLOR: '5763719'

notify_failure:
  extends: .discord_notify
  when: on_failure
  variables:
    STATUS_TEXT: '❌ Пайплайн упал:'
    STATUS_COLOR: '15548997'

Что здесь важно:

  • when: on_failure запускает джоб, только если упал джоб на одной из предыдущих стадий; when: on_success (значение по умолчанию) — только если всё прошло. За пайплайн срабатывает не больше одного из двух; у отменённого пайплайна не запустится ни один.
  • url в embed превращает заголовок в ссылку на пайплайн. CI_PIPELINE_URL, CI_COMMIT_TITLE, CI_COMMIT_SHORT_SHA и GITLAB_USER_NAME — предопределённые переменные, настраивать ничего не нужно.
  • --argjson color превращает строковую переменную в число: color должен быть десятичным целым, а не "#57F287".
  • curl -f роняет джоб при ответе 4xx/5xx вместо тихого «зелёного»; --retry 3 покрывает временные ошибки, включая 429.

Алерт о падении конкретного джоба через after_script

Если нужно знать, какой именно джоб упал, но добавлять стадию не хочется, используйте after_script. Там доступна CI_JOB_STATUS со значениями success, failed или canceled:

.alert_on_fail:
  after_script:
    - |
      if [ "$CI_JOB_STATUS" = "failed" ]; then
        curl -sS -H "Content-Type: application/json" \
          -d "{\"content\": \"❌ **$CI_JOB_NAME** упал на \`$CI_COMMIT_REF_NAME\` — [открыть джоб]($CI_JOB_URL)\"}" \
          "$DISCORD_WEBHOOK_URL"
      fi

test:
  extends: .alert_on_fail
  script:
    - npm test

Jenkins: блок post {} в декларативном пайплайне

У декларативного пайплайна есть секция post, которая выполняется после всех стадий. Вместо того чтобы копировать curl в success, failure и unstable, сопоставьте currentBuild.currentResult с цветом в одной функции и вызывайте её из always:

pipeline {
  agent any

  stages {
    stage('Build') {
      steps { sh 'npm ci && npm run build' }
    }
    stage('Test') {
      steps { sh 'npm test' }
    }
  }

  post {
    always {
      discordNotify(currentBuild.currentResult)
    }
  }
}

def discordNotify(String result) {
  def colors = [SUCCESS: 5763719, FAILURE: 15548997, UNSTABLE: 16705372, ABORTED: 9807270]
  def icons  = [SUCCESS: '✅', FAILURE: '❌', UNSTABLE: '⚠️', ABORTED: '⏹️']

  def payload = groovy.json.JsonOutput.toJson([
    username: 'Jenkins',
    embeds: [[
      title: "${icons[result]} ${env.JOB_NAME} #${env.BUILD_NUMBER}: ${result}",
      url: env.BUILD_URL,
      color: colors[result],
      fields: [
        [name: 'Ветка', value: env.GIT_BRANCH ?: 'n/a', inline: true],
        [name: 'Длительность', value: currentBuild.durationString.replace(' and counting', ''), inline: true]
      ]
    ]]
  ])

  writeFile file: 'discord-payload.json', text: payload
  withCredentials([string(credentialsId: 'discord-webhook-url', variable: 'DISCORD_WEBHOOK_URL')]) {
    sh 'curl -sS -f -H "Content-Type: application/json" -d @discord-payload.json "$DISCORD_WEBHOOK_URL"'
  }
}

Почему именно так:

  • JsonOutput.toJson экранирует кавычки и переводы строк, а writeFile + -d @file полностью выводит payload из-под кавычек shell.
  • withCredentials подставляет секрет и маскирует его в консоли: даже эхо от set -x покажет ****; строка sh в одинарных кавычках оставляет подстановку $DISCORD_WEBHOOK_URL шеллу, а не Groovy.
  • currentBuild.durationString заканчивается на « and counting», пока сборка идёт, а внутри post она всегда ещё идёт — отсюда replace.

Плагин HTTP Request вместо curl

Если на агенте нет curl, тот же запрос делает плагин HTTP Request — внутри того же блока withCredentials:

httpRequest url: env.DISCORD_WEBHOOK_URL, httpMode: 'POST',
  contentType: 'APPLICATION_JSON', requestBody: payload, validResponseCodes: '200:204'

Плагин пишет URL запроса в консоль, но внутри withCredentials фильтр логов его замаскирует.

Плагин Discord Notifier

Если JSON не хочется вовсе, плагин Discord Notifier добавляет шаг discordSend, который сам раскрашивает embed по результату сборки:

post {
  always {
    withCredentials([string(credentialsId: 'discord-webhook-url', variable: 'DISCORD_WEBHOOK_URL')]) {
      discordSend(
        webhookURL: env.DISCORD_WEBHOOK_URL,
        title: "${env.JOB_NAME} #${env.BUILD_NUMBER}",
        link: env.BUILD_URL,
        result: currentBuild.currentResult,
        description: "Ветка: ${env.GIT_BRANCH}\nДлительность: ${currentBuild.durationString}",
        footer: 'Jenkins'
      )
    }
  }
}

С curl вы владеете всем payload (до 25 полей, картинки, несколько embed); с плагином — тем, что выведено в его параметры.

Цвет по статусу

color — десятичное целое; небольшой палитры хватает на все состояния CI:

СостояниеHexDecimal
Прошёл#57F2875763719
Упал#ED424515548997
Unstable / предупреждение#FEE75C16705372
Выполняется#5865F25793266
Отменён#95A5A69807270

В shell hex переводится в decimal через echo $((16#ED4245)); больше оттенков — в статье про цвета embed в Discord. Дублируйте статус словом в заголовке: коллеги с нарушением цветовосприятия и превью push-уведомления на телефоне боковую полоску не видят.

Убираем шум: одно сообщение на пайплайн

Двенадцать джобов, двенадцать зелёных сообщений — типичная беда. Четыре правила её лечат.

Уведомляйте из одного места. Финальная стадия notify в GitLab, post на уровне пайплайна в Jenkins. Не по джобу и не по стадии — исключение только для алертов о падениях.

Успех — только там, где он важен. Ветка по умолчанию и теги получают и успех, и падения; feature-ветки — только падения; пайплайны merge request — ничего, их статус и так виден в виджете MR:

notify_success:
  extends: .discord_notify
  variables:
    STATUS_TEXT: '✅ Пайплайн прошёл:'
    STATUS_COLOR: '5763719'
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH || $CI_COMMIT_TAG
      when: on_success

notify_failure:
  extends: .discord_notify
  variables:
    STATUS_TEXT: '❌ Пайплайн упал:'
    STATUS_COLOR: '15548997'
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      when: never
    - when: on_failure

В Jenkins — по факту изменения. failure плюс fixed дают красное сообщение и ровно одно зелёное, когда сборка починилась, вместо зелёного на каждую сборку:

post {
  failure { discordNotify('FAILURE') }
  fixed   { discordNotify('SUCCESS') }
}

Редактируйте вместо повторной отправки. Отправьте сообщение «выполняется» с ?wait=true, сохраните id из ответа (в GitLab запишите его в артефакт dotenv, чтобы следующие джобы его видели), а в конце сделайте PATCH этого сообщения с итоговым payload. Одна строка на пайплайн, всегда актуальная:

MSG_ID=$(curl -sS -H "Content-Type: application/json" -d "$RUNNING_PAYLOAD" \
  "$DISCORD_WEBHOOK_URL?wait=true" | jq -r .id)
# позже, в джобе notify:
curl -sS -f -X PATCH -H "Content-Type: application/json" -d "$PAYLOAD" \
  "$DISCORD_WEBHOOK_URL/messages/$MSG_ID"

Полный цикл — в статье про редактирование и удаление сообщений вебхука. Какой бы путь вы ни выбрали, монорепозиторий, где постит каждый джоб, быстро упрётся в 429 — см. лимиты Discord webhook.

Частые ошибки

Джоб зелёный, а в Discord пусто. Без -f curl завершается с кодом 0 даже на 400 или 404. Добавьте -f -sS (или --fail-with-body, curl 7.76+: джоб падает, но тело ответа всё равно печатается) либо печатайте статус через -w '%{http_code}', затем читайте тело ответа: Discord называет невалидное поле. Голый -f тело скрывает.

400 Bad Request после конкретного коммита. В заголовке коммита была кавычка или обратный слеш, и он попал прямо в JSON-строку. Собирайте payload через jq --arg или JsonOutput.toJson, а не вручную в shell.

{"message": "Unknown Webhook", "code": 10015}. URL неверный или вебхук удалён в Discord. Если же curl завершается с (3) URL using bad/illegal format or missing URL, переменная здесь пуста: protected-переменная не видна незащищённым веткам, переменная с environment scope — вне своего окружения. Безопасная проверка — echo ${#DISCORD_WEBHOOK_URL}: длина 0 значит, что переменной нет.

notify_failure не запускается, хотя джоб красный. У красного джоба стоит allow_failure: true, и GitLab считает пайплайн успешным. Падения таких джобов on_failure не запускают.

post { failure } в Jenkins молчит при упавших тестах. junit помечает сборку как UNSTABLE, а не FAILED. Обрабатывайте unstable тоже или используйте always с currentBuild.currentResult, как выше.

No such DSL method 'httpRequest'. Плагин не установлен на этом контроллере: поставьте его или вернитесь к sh 'curl …'.

Токен засветился в логе. GitLab маскирует только точное значение переменной: выведите через echo декодированную или изменённую копию — и токен виден. Отзовите вебхук (удалите и создайте заново в Discord) и почините скрипт. То же правило в Jenkins вне withCredentials.

429 Too Many Requests. Слишком много джобов постят одновременно: уважайте retry_after и переходите на одну стадию notify.

FAQ

Может ли GitLab писать в Discord вообще без YAML?

Да. В GitLab есть интеграция Discord Notifications (Settings → Integrations → Discord Notifications): вставляете URL вебхука, и она шлёт события пайплайнов, пушей и merge request. Для простых пингов достаточно; джоб с curl даёт свой макет embed и цвета.

Как упомянуть человека или роль при падении пайплайна?

Поместите <@&ROLE_ID> в content и явно разрешите упоминание: "allowed_mentions": {"roles": ["ROLE_ID"]}. Без allowed_mentions Discord парсит все упоминания в content, и случайный @everyone из заголовка коммита пингует весь сервер; явный список ролей ограничивает пинг только этой ролью. Подробности — в статье про упоминания через вебхук.

Можно ли приложить к сообщению лог тестов или отчёт о покрытии?

Да, переключитесь на multipart/form-data: curl -F 'payload_json={"content":"Лог тестов"}' -F 'files[0][email protected]' "$DISCORD_WEBHOOK_URL". До 10 файлов по 10 МБ по умолчанию. См. отправку файлов через вебхук.

Работает ли это в scripted-пайплайнах Jenkins?

Да. Блока post там нет, поэтому оберните стадии в try { … } catch (e) { currentBuild.result = 'FAILURE'; throw e } finally { discordNotify(currentBuild.currentResult) }.

Итог

Постите из одного места, собирайте JSON настоящим сериализатором, красьте по статусу и оставляйте «успех» только для веток, за которыми следят. Чтобы спроектировать embed до того, как писать скрипт, откройте конструктор Discord Webhook, соберите поля и цвета визуально и экспортируйте JSON прямо в .gitlab-ci.yml или Jenkinsfile.

Смежные статьи:

Теги: gitlab cijenkinsci/cddiscord webhookвебхук discordуведомления о сборкахdevops

Похожие статьи

Все статьи

Соберите это в визуальном редакторе

Embed, Components V2, кнопки и опросы с живым предпросмотром Discord. Бесплатно, без регистрации.