Implement Webhooks for Buildkite

Buildkite -> Your Server -> Notifications Channel

When you run your builds on Buildkite, Buildkite can issue Webhooks that you could listen and send to your preferred notification channels.

You can choose what kind of events you want to receive on Buildkite’s settings page.

Notifications Channels: Slack, Basecamp, Custom Server, etc.

Let’s see how to implement this part.

Buildkite -> Your Server
# frozen_string_literal: true

require "json"

class BuildkiteWebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token, only: :create

  before_action :verify_buildkite_request

  def create
    # This will call a background job
    BuildkiteEvent.process(event: buildkite_event, body: body)

    head :ok
  rescue StandardError => exception
    # Replace with your Exception Notification mechanism
    Rails.logger.error(exception.message)

    head :unprocessable_entity
  end

  private

  # https://buildkite.com/organizations/-/services > Webhook to get the token
  BUILDKITE_WEBHOOK_TOKEN = ENV.fetch("BUILDKITE_WEBHOOK_TOKEN", "developmentoken").freeze
  private_constant :BUILDKITE_WEBHOOK_TOKEN

  def verify_buildkite_request
    unless request_from_buildkite?
      raise "Could not verify Buildkite request. X-Buildkite-Request: '#{buildkite_request}'"
    end
  end

  def buildkite_event
    request.headers["X-Buildkite-Event"]
  end

  def body
    JSON.parse(request.raw_post)
  end

  # Use secure_compare, constant-time comparison
  def request_from_buildkite?
    Rack::Utils.secure_compare(buildkite_token, BUILDKITE_WEBHOOK_TOKEN)
  end

  def buildkite_token
    request.headers["X-Buildkite-Token"].to_s
  end

  def buildkite_request
    request.headers["X-Buildkite-Request"]
  end
end

and BuildkiteEvent to dispatch the request to different background job:

# frozen_string_literal: true

class BuildkiteEvent
  def self.process(event:, body:)
    new(event, body).process
  end

  def initialize(event, body)
    @event = event
    @body = body
  end

  def process
    return unless event.include?("build")

    # Post to a different channel
    if deploy?
      BuildkiteDeployNotificationJob.perform_later(payload.build)
    else
      BuildkiteBuildNotificationJob.perform_later(payload.build)
    end
  end

  private

  attr_reader :event, :body

  def deploy?
    body["pipeline"]["slug"] == "deploy"
  end

  def payload
    BuildkitePayload.new(body)
  end
end

and BuildkitePayload:

# frozen_string_literal: true

class BuildkitePayload
  def initialize(data)
    @data = data
  end

  def build
    _build = {
      "id" => build_hash["id"],
      "url" => build_hash["url"],
      "web_url" => build_hash["web_url"],
      "number" => build_hash["number"],
      "state" => build_hash["state"],
      "blocked" => build_hash["blocked"],
      "blocked_state" => build_hash["blocked_state"],
      "message" => build_hash["message"],
      "commit" => build_hash["commit"],
      "branch" => build_hash["branch"],
      "tag" => build_hash["tag"],
      "source" => build_hash["source"],
      "author" => build_hash["author"],
      "created_at" => build_hash["created_at"],
      "scheduled_at" => build_hash["scheduled_at"],
      "started_at" => build_hash["started_at"],
      "finished_at" => build_hash["finished_at"],
      "pull_request" => build_hash["pull_request"],
      "rebuilt_from" => build_hash["rebuilt_from"],
      "meta_data" => build_hash["meta_data"],
    }
    _build.merge!(author_hash) if build_hash["author"].present?
    _build.merge!(creator_hash) if build_hash["creator"].present?
    _build
  end

  private

  attr_reader :data

  def build_hash
    @_build_hash ||= data.fetch("build", {})
  end

  def author_hash
    {
      "author" => {
        "username" => build_hash["author"]["username"],
        "name" => build_hash["author"]["name"],
        "email" => build_hash["author"]["email"],
      }
    }
  end

  def creator_hash
    {
      "creator" => {
        "id" => build_hash["creator"]["id"],
        "name" => build_hash["creator"]["name"],
        "email" => build_hash["creator"]["email"],
        "avatar_url" => build_hash["creator"]["avatar_url"],
        "created_at" => build_hash["creator"]["created_at"],
      },
    }
  end
end

To demo this app, I use the Active Job’s async adapter. For your production use, you should consider Resque or Sidekiq.

That’s it.

You could also implement this in Python, Go, Elixir, and any other programming language of your choice.

Cheers,
Juanito