Automated on-demand deployments with Semaphore CI Classic API (and Ruby)
Mariusz Hausenplas
Posted on May 11, 2020
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!
-
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
-
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 withbuild_number
andresult
: we'll need these values for later. -
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 thebuild_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?
-
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
Posted on May 11, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.