Automated on-demand deployments with Semaphore CI Classic API (and Ruby)

xlts

Mariusz Hausenplas

Posted on May 11, 2020

Automated on-demand deployments with Semaphore CI Classic API (and Ruby)

Semaphore is a popular tool used to create CI/CD pipelines. It does a great job at quickly integrating with Git repositories and running build or deployment tasks. Additionally, it comes with two different flavours: the standard Semaphore Classic and, the much more sophisticated Semaphore 2.0.

While I'm not fully familiar with Semaphore 2.0 capabilities, one thing I can tell about Semaphore Classic is that it feels like one of the good, old Unix programs that was designed to do one simple thing, and do it well. Or, perhaps more accurately, it feels like a tool that was built with the Convention over Configuration rule in mind: it can definitely do a lot of things, but only in a specific way, and under specific assumptions.

In this post I'll explore the surprisingly underdocumented capabilities of Semaphore Classic HTTP API to create a custom workflow supporting on-demand deployments to different servers.

Now, imagine you've submitted a pull request and you'd like to test it somehow, e.g. on a staging environment. At this point you've probably already defined a new staging server that Semaphore could deploy to. So, all you have to do is to select a branch, pick a build and click Deploy manually and select your non-production server. Pretty easy, eh? Well, what if you misclicked and accidentally deployed to production? Of course you could count on your reflexes and instantly stop the deploy, but we all know that it's better to automate such risky processes. Human error is inevitable. So where do we start?

Semaphore CI operates on a small number of easy-to-comprehend concepts: Branches, Builds, Servers and Deploys. As we saw in the previous paragraph, a build is triggered by some kind of an external event (e.g. by a new brach being created or a new pull request being submitted). Then, you'd usually want CI to deploy this build to a specific server. These concepts map to entities in the HTTP API:

With this knowledge in mind, let's try to automate something!

  1. URL structure & basic Semaphore API client setup

    In this preliminary step, let's create a simple Ruby class that uses the HTTParty gem and lists required configuration variables that'll be passed in requests to Semaphore API.

    class SemaphoreClient
      include HTTParty
    
      PROJECT_ID = ENV["SEMAPHORE_PROJECT_ID"]
      SERVER_ID = ENV["SEMAPHORE_SERVER_ID"]
    
      base_uri "https://semaphoreci.com/api/v1/projects/#{PROJECT_ID}"
    
      default_params auth_token: ENV["SEMAPHORE_API_AUTH_TOKEN"]
    end
    
  • base_uri method defines the root URL of Semaphore Classic API
  • auth_token param will be passed in every request. This is the User Authentication Token found in Account Settings
  • PROJECT_ID is an ID of a Semaphore project, corresponding to a Git repository. This is a bit tricky to find: you can either query the Projects API to find your repo, or go to Project Badge Settings where you can copy-paste the ID from provided snippets. This should look something like this: https://semaphoreci.com/api/v1/projects/<project_id>/badge.svg.
  • SERVER_ID is an ID of a target deployment server, e.g. the one you'd like to use to deploy to your Staging/Preview environment. It seems that the only way to fetch internal IDs of servers is via the Servers API
  1. Check branch build status

    First, we need to check if a Semaphore build completed succesfully on a branch. In other words, let's see if the PR is "green" (build passed), "yellow" (build pending) or "red" (build failed):

    class SemaphoreClient
    
      ...
    
      class << self
        def pr_status(pr_number:)
          response = get(
            "/pull-request-#{pr_number}/status"
          )
    
          {
            build_number: response["build_number"],
            result: response["result"]
          }
        end
      end
    end
    

    As you can see, calling SemaphoreClient.pr_status("1234") should return a simple hash with build_number and result: we'll need these values for later.

  2. Trigger deployment

    If result is "passed", this means that the Semaphore build has passed and can be safely deployed. Of course, it's also possible to deploy a running build (if you're brave enough). Anyway, let's add another method which'll use the build_number that we received in previous call.

    class SemaphoreClient
    
      ...
    
      class << self
        def trigger_deploy(pr_number:, build_number:)
          response = post(
            "/pull-request-#{pr_number}/builds/" \
            "#{build_number}/deploy/#{SERVER_ID}"
          )
    
          {
            number: response["number"],
            result: response["result"]
          }
        end
      end   
    end
    

    Ta-da! You've just triggered a deployment to a non-default server without having to click through the Semaphore user interface. Time to pop the champagne?

  3. Check deployment status

    Finally, it'd be good to monitor whether a deployment completed successfully. Let's add one more method:

    class SemaphoreClient
    
      ...
    
      class << self
        def deploy_status(deploy_number:)
          response = get(
            "/servers/#{SERVER_ID}/deploys/#{deploy_number}"
          )
    
          {
            number: response["number"],
            result: response["result"]
          }
        end
      end
    end
    

And that's it. It's a very simplistic solution, yet should be enough to serve as a proof of concept for futher integration with Github Actions, a Github/Gitlab/Bitbucket bot or any other tool automating your Semaphore CI workflows.

Finally, for the record, here's the entire SemaphoreClient class. Again, please treat it as a basic PoC: it definitely calls for some error handling and, depending on your needs, you might want to fetch more data from the API responses. Anyway, happy integrating with Semaphore!

class SemaphoreClient
  include HTTParty

  PROJECT_ID = ENV["SEMAPHORE_PROJECT_ID"]
  SERVER_ID = ENV["SEMAPHORE_SERVER_ID"]

  base_uri "https://semaphoreci.com/api/v1/projects/#{PROJECT_ID}"

  default_params auth_token: ENV["SEMAPHORE_API_AUTH_TOKEN"]

  class << self
    def pr_status(pr_number:)
      response = get(
        "/pull-request-#{pr_number}/status"
      )

      {
        build_number: response["build_number"],
        result: response["result"]
      }
    end

    def trigger_deploy(pr_number:, build_number:)
      response = post(
        "/pull-request-#{pr_number}/builds/" \
        "#{build_number}/deploy/#{SERVER_ID}"
      )

      {
        number: response["number"],
        result: response["result"]
      }
    end

    def deploy_status(deploy_number:)
      response = get(
        "/servers/#{SERVER_ID}/deploys/#{deploy_number}"
      )

      {
        number: response["number"],
        result: response["result"]
      }
    end
  end
end
💖 💪 🙅 🚩
xlts
Mariusz Hausenplas

Posted on May 11, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related