Our team has a (not so) slight tendency to not immediately follow through with our deployments to production. We’ll create and review our changes, merge and deploy to staging, and dilligently test the changes there. And then…​ nothing happens.

It could be that something else needs our immediate attention, or someone else wants to confirm an issue is fixed; Or we might want to deploy at a different point in time as to not disrupt an ongoing process by a service restart. Any which way the result is the same: changes accumulate in staging, and with that the risk involved with the next production deployment.

To nudge ourselves to deploy to production more often we created a Slack App that gives us a daily report of such pending deployments. In this post I’ll showcase the code we use, and how to set up something similar yourself.

Background & Goal

We use GitLab CI for our build pipelines, which include deployment to various environments. By tracking our environments in GitLab we can easily see per service what was the last deployment to each environment, and what changes have since been made to the default branch. The same environments and deployments are also available through the GitLab API, which we’ll use to extract changes that have not yet gone to production.

Since our team uses Slack, we thought it easiest to remind ourselves every morning who made what changes that are still pending for deployment. That way it’s immediately obvious who’s in charge, and any impediments can be discussed in the morning stand-up meeting.

slack thread
Figure 1. Sample early morning Slack thread reminder of pending deployments

Project structure

The project is set up as a very basic Spring Boot application, which we run in GitLab CI through mvnw spring-boot:run. This has the advantage that it can easily be run on a schedule rather than as a service, since we only need the reports periodically.

Aside from Spring Boot, the only dependencies we need are on org.gitlab4j:gitlab4j-api & com.slack.api:slack-api-client.

Configuration is handled through a mix of environment variables declared in .gitlab-ci.yml, variables declared per scheduled pipeline report, as well as CI / CD Variables for secret tokens.

View project pom.xml
Listing 1. pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.4.4</version>
		<relativePath /> <!-- lookup parent from repository -->
	</parent>
	<groupId>com.company.team</groupId>
	<artifactId>pending-deployments</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>pending-deployments</name>
	<description>Pending deployments</description>
	<properties>
		<java.version>16</java.version>
	</properties>
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter</artifactId>
		</dependency>
		<dependency>
			<groupId>org.gitlab4j</groupId>
			<artifactId>gitlab4j-api</artifactId>
			<version>4.15.7</version>
		</dependency>
		<dependency>
			<groupId>com.slack.api</groupId>
			<artifactId>slack-api-client</artifactId>
			<version>1.7.1</version>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
	</dependencies>
	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
			</plugin>
		</plugins>
	</build>
</project>
View project .gitlab-ci.yml
Listing 2. .gitlab-ci.yml
image: openjdk:16

report:
  only:
    - schedules
  variables:
    gitlabhost: https://your.gitlab.host
    targetenvironment: production
  script:
    - './mvnw $MAVEN_CLI_OPTS spring-boot:run'
View project CI / CD Variables
cicd variables
View scheduled pipeline report
pipeline schedule
View Java ApplicationRunner outline
@Component
class ReportPendingDeployments implements ApplicationRunner {

  private static final Logger log = LoggerFactory.getLogger(ReportPendingDeployments.class);

  private final org.springframework.core.env.Environment systemEnv;

  public ReportPendingDeployments(org.springframework.core.env.Environment systemEnv) {
    this.systemEnv = systemEnv;
  }

  @Override
  public void run(ApplicationArguments args) throws Exception {
    String environmentName = systemEnv.getProperty("targetenvironment");
    List<PendingDeployment> pendingDeployments = extractPendingDeploymentsFromGitLab(environmentName);
    reportToSlack(environmentName, pendingDeployments);
  }

  [...]

}

GitLab API

We use the excellent gitlab4j-api to extract projects, environments, deployments, and through those any pending changes. Gitlab4j-api closely follows the GitLab V4 API resources, so it helps to look at documentation for both when developing. We authenticate using a personal access token using the read_api scope, which we provide as environment variable through CI / CD Variables as shown above.

As our organization has split teams by programming language, we look at all recently updated projects using an argument programming language. For those projects we evaluate if there are any pending deployments, and if there are we report those to Slack.

Listing 3. Extract relevant projects
private List<PendingDeployment> extractPendingDeploymentsFromGitLab(String environmentName)
    throws GitLabApiException {

  // Extract GitLab arguments
  String personalAccessToken = systemEnv.getProperty("accesstoken");
  if (personalAccessToken == null || personalAccessToken.isBlank()) {
    throw new IllegalStateException("GitLab accesstoken required");
  }
  String hostUrl = systemEnv.getProperty("gitlabhost");
  String programmingLanguage = systemEnv.getProperty("language");

  try (GitLabApi api = new GitLabApi(hostUrl, personalAccessToken)) {
    // Retrieve actively maintained projects in team language
    return api.getProjectApi()
        .getProjectsStream(new ProjectFilter()
            .withArchived(false)
            .withSimple(true)
            .withProgrammingLanguage(programmingLanguage)
            .withOrderBy(ProjectOrderBy.NAME)
            .withSortOder(SortOrder.ASC))
        .filter(project -> project.getLastActivityAt()
            .after(Date.from(Instant.now().minus(Duration.ofDays(90)))))
        .flatMap(project -> {
          try {
            // Evaluate project & return optional PendingDeployment
            return evaluateProject(api, environmentName, project).stream();
          } catch (GitLabApiException e) {
            log.warn("Failed to evaluate {}", project.getPathWithNamespace(), e);
            return Stream.empty();
          }
        })
        .collect(Collectors.toList());
  }
}

Our evaluate function takes an argument project and environment, and using the GitLabApi determines whether there are any pending deployments for the project. If there are pending deployments, we call another function to create and return a Slack formatted markdown message.

Listing 4. Evaluate pending deployments per project
private static Optional<PendingDeployment> evaluateProject(
  GitLabApi api,
  String environment,
  Project project)
    throws GitLabApiException {

  // Get target project environment
  Optional<Environment> envSimple = api.getEnvironmentsApi()
      .getEnvironmentsStream(project.getId())
      .filter(env -> env.getName().equalsIgnoreCase(environment))
      .findFirst();
  if (envSimple.isEmpty()) {
    log.info("No environment {} found for {}", environment, project.getPathWithNamespace());
    return Optional.empty();
  }

  // Get latest deployed sha
  Deployment deployment = api.getEnvironmentsApi()
      .getEnvironment(project.getId(), envSimple.get().getId())
      .getLastDeployment();
  if (deployment == null) {
    log.info("No deployment found for environment {} for {}", environment, project.getPathWithNamespace());
    return Optional.empty();
  }

  // Compare with default branch
  String lastDeployedSha = deployment.getSha();
  CompareResults compareResults = api.getRepositoryApi()
      .compare(project.getId(),
          lastDeployedSha,
          project.getDefaultBranch());
  if (compareResults.getCommits().size() == 0) {
    log.info("No changes found comparing {} with {} for {}", environment,
        project.getDefaultBranch(), project.getPathWithNamespace());
    return Optional.empty();
  }

  // Report undeployed commits for project
  var pendingDeployment = composeMessageForPendingDeployment(api, project, lastDeployedSha, compareResults);
  log.info("{}", pendingDeployment);
  return Optional.of(pendingDeployment);
}

Our markdown formatting function extracts the pending commits, and for those commits the authors involved. URLs are composed to show the differences compared with the main branch, as well as the latest commit pipeline. Finally all input is combined to show a single succinct markdown formatted message per project.

Listing 5. Convert pending deployments to markdown
private static PendingDeployment composeMessageForPendingDeployment(
  GitLabApi api,
  Project project,
  String lastDeployedSha,
  CompareResults compareResults)
    throws GitLabApiException {

  // Extract authors for pending changes
  var authors = compareResults.getCommits().stream()
      .map(Commit::getAuthorName)
      .distinct()
      .sorted()
      .collect(Collectors.joining(" & "));

  // Compose comparison view url
  String compareUrl = "%s/-/compare/%s...%s".formatted(
      project.getWebUrl(),
      lastDeployedSha,
      project.getDefaultBranch());

  // Get last pipeline for commit
  Pipeline lastPipeline = api.getCommitsApi()
      .getCommit(project.getId(), compareResults.getCommit().getId())
      .getLastPipeline();
  // Get web url for last pipeline
  String pipelineUrl = api.getPipelineApi()
      .getPipeline(project.getId(), lastPipeline.getId())
      .getWebUrl();

  // Compose pending deployment
  return new PendingDeployment(
      project.getPathWithNamespace(),
      compareResults.getCommits().size(),
      authors,
      compareUrl,
      pipelineUrl);
}

record PendingDeployment(
    String pathWithNamespace,
    int numberOfCommits,
    String authors,
    String compareUrl,
    String pipelineUrl) {
}

All together we end up with a list of markdown messages each reporting on a project with pending deployments to our target environment.

Slack API

Once we’ve identified our pending deployments, we want to report these to Slack for visibility, such that the team can evaluate and deploy outstanding changes. To communicate with Slack we use the standard slack-api-client for Java. Given a token, a channel and a list of markdown messages we wish to report these to a single thread per day, to keep noise to a minimum.

Listing 6. Report pending deployments to Slack thread
private void reportToSlack(
  String environmentName,
  List<PendingDeployment> pendingDeployments)
    throws Exception {

  // Extract Slack arguments
  String channel = systemEnv.getProperty("channel");
  String slackToken = systemEnv.getProperty("slacktoken");
  if (channel == null || slackToken == null || slackToken.isBlank() || pendingDeployments.isEmpty()) {
    log.warn("Channel and slacktoken required");
    return;
  }

  try (Slack slack = Slack.getInstance()) {
    MethodsClient methods = slack.methods(slackToken);

    // Start a thread
    ChatPostMessageResponse response = methods
      .chatPostMessage(req -> req
        .channel(channel)
        .text("%d projects have changes not yet deployed to %s"
          .formatted(pendingDeployments.size(), environmentName)));
    if (!response.isOk()) {
      // e.g., "invalid_auth", "channel_not_found"
      String errorCode = response.getError();
      log.error("Failed to start thread: {}", errorCode);
      return;
    }

    // Post markdown messages in thread
    String threadTs = response.getMessage().getTs();
    for (PendingDeployment pendingDeployment : pendingDeployments) {
      try {
        ChatPostMessageResponse threadResponse = methods
          .chatPostMessage(req -> req
            .threadTs(threadTs)
            .channel(channel)
            .text("%s has <%s|%d commits> by %s not yet <%s|deployed to %s>"
              .formatted(
                  pendingDeployment.pathWithNamespace(),
                  pendingDeployment.compareUrl(),
                  pendingDeployment.numberOfCommits(),
                  pendingDeployment.authors(),
                  pendingDeployment.pipelineUrl(),
                  environmentName)));
        if (!threadResponse.isOk()) {
          log.warn("Failed to post message to thread: {}", threadResponse.getError());
        }
      } catch (IOException | SlackApiException e) {
        log.warn("Failed to post message to thread: {}", e.getMessage());
      }
    }
  }
}

That concludes the code we need to both extract pending deployments from GitLab, as well as report those messages to Slack.

Slack App

We need to create a Slack App before we’re able to communicate with the Slack API to write chat messages. Give your App a name; we chose "Pending Deployments", and add the App to your Slack Workspace. Next you need to configure the Bot Token Scopes for your App to include the chat:write scope.

scopes
Figure 2. Slack App OAuth & Permissions

Copy the Bot User OAuth Token for your App, which starts with xoxb-, and store it as slacktoken in your GitLab CI / CD Variables. Be sure to add the bot to any channel where you want to post messages, through for instance: /add @pending_deployments.

Pipeline Schedule

Finally, we can schedule multiple pipelines to each report projects using a given language to team Slack channels. We’ve chosen daily early morning reminders, but choose whatever works best for your team and flow. Notice how we only define variables for language and channel here, as they vary from schedule to schedule.

pipeline schedule
Figure 3. Workday mornings pipeline schedule

Conclusion

That’s all you need to extract projects with pending deployments and report those to Slack. We’ve found these gentle reminders nudge us daily to deploy our changes to production as needed.

shadow-left